Import LinuxCNC source tree

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

3
configs/sim/axis/mtconnect/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
# 3D model assets are user-supplied. The example uses MODEL_AUTO (generated box
# geometry) and needs no mesh files, so the whole directory stays untracked.
models/

View File

@@ -0,0 +1,279 @@
# MTConnect agent for LinuxCNC — demo config
First-class MTConnect support for LinuxCNC: a userspace, non-realtime agent that
exposes machine **status**, a rich **kinematic description**, and **tool data**
over MTConnect. It ships an embedded HTTP agent (no external dependency) and can
optionally publish over the standard MTConnect **MQTT** binding. The `/probe`
response carries enough kinematic detail for an external tool (e.g. a FreeCAD
Path plugin) to auto-configure a machine.
This directory is a demo sim config. The agent itself installs with LinuxCNC
(`bin/mtconnect-agent`, package `lib/python/mtc`, assets under
`share/linuxcnc/mtconnect`). For the full reference see the `mtconnect-agent(1)`
man page and the `[MTCONNECT]` section of the INI configuration chapter.
## Quick start
```sh
# From this directory:
linuxcnc example.ini # launches the sim + the MTConnect agent
# In another terminal:
curl http://localhost:5000/probe # MTConnectDevices (structure + kinematics)
curl http://localhost:5000/current # latest value of every DataItem
curl http://localhost:5000/sample?from=1&count=100
curl http://localhost:5000/assets # CuttingTool assets (tool table)
```
You can inspect the generated device model without launching LinuxCNC (after
sourcing `scripts/rip-environment` in a RIP build, or with LinuxCNC installed):
```sh
mtconnect-agent --dump-probe example.ini
mtconnect-agent --dump-probe ../vismach/5axis/table-rotary-tilting/xyzac-trt.ini
```
## Enabling it in your own config
Add a few lines to your INI. The device model is generated automatically from
`[TRAJ]`, `[KINS]`, `[AXIS_*]` and `[JOINT_*]`.
```ini
[MTCONNECT]
ENABLE = 1
DEVICE_NAME = my_mill
UUID = linuxcnc-my-mill-0001
HTTP_PORT = 5000
# HTTP_BIND defaults to 127.0.0.1 (loopback); set 0.0.0.0 to expose on the LAN.
# HTTP_BIND = 127.0.0.1
# TRANSPORT: comma list of http, mqtt, shdr (no inline comments in INI values)
TRANSPORT = http
SAMPLE_HZ = 10
# MQTT_BROKER = localhost
# MQTT_PORT = 1883
# MQTT_PREFIX = MTConnect
```
Load the agent from a HAL file (see `mtconnect.hal`):
```
loadusr -W mtconnect-agent
```
`-W` waits until the component is ready; the INI comes from `$INI_FILE_NAME`.
Loading it in the HAL file (rather than `[APPLICATIONS]`) makes the status pins
(`active`, `connected`) available for linking.
## What is exposed
| Endpoint | Content |
|------------|---------|
| `/probe` | Device structure: Controller/Path, Axes (Linear/Rotary + Motion), spindle, and the `x:Kinematics` extension |
| `/current` | Latest value of every DataItem (execution, mode, positions, spindle, feed, tool) |
| `/sample` | Sequence-numbered observation history (`?from=&count=`) |
| `/assets` | Tool table as `CuttingTool` assets (location, diameter, length offsets) |
### LinuxCNC → MTConnect mapping (`linuxcnc.stat()`)
| MTConnect DataItem | Source |
|---|---|
| `EMERGENCY_STOP` | `task_state` |
| `CONTROLLER_MODE` | `task_mode` |
| `EXECUTION` | `interp_state`, `task_paused` |
| `PROGRAM`, `LINE_NUMBER` | `file`, `motion_line` (executing line, not interp read-ahead) |
| `PATH_FEEDRATE` (+ OVERRIDE) | `current_vel`, `feedrate` |
| `POSITION` / `ANGLE` (ACTUAL/COMMANDED) | `actual_position`, `position` |
| `ROTARY_VELOCITY`, `ROTARY_MODE`, `DIRECTION` | `spindle[0]` |
| `TOOL_NUMBER`, `TOOL_ASSET_ID` | `tool_in_spindle` |
| `ASSET_CHANGED` | tool change detection |
| `CuttingTool` assets | `tool_table` |
## The kinematic description (`x:Kinematics`)
Standard MTConnect models axes as `Linear`/`Rotary` components with `Motion`
elements (`PRISMATIC`/`REVOLUTE`, direction vector). LinuxCNC specifics that do
not map cleanly are carried in a versioned extension namespace
`urn:linuxcnc:mtconnect:1` — the primary contract for auto-configuration:
```xml
<x:Kinematics module="xyzac-trt-kins" coordinates="XYZAC" joints="5"
params="sparm=identityfirst">
<x:JointMap>
<x:Joint number="0" kind="LINEAR" axis="X" min="-200" max="200" home="0"/>
<x:Joint number="3" kind="ANGULAR" axis="A" min="-100" max="50" home="0"/>
<x:Joint number="4" kind="ANGULAR" axis="C" min="-36000" max="36000" home="0"/>
</x:JointMap>
<x:Axis name="X" kind="LINEAR" vector="1 0 0" min="-200" max="200"/>
<x:Axis name="A" kind="ANGULAR" vector="1 0 0" min="-100" max="50"/>
<x:Axis name="C" kind="ANGULAR" vector="0 0 1" min="-36000" max="36000"/>
</x:Kinematics>
```
A FreeCAD plugin reads the standard `Axes`/`Motion` tree, or the compact
`x:Kinematics` block, to create/configure a machine: axis list and type,
travel limits, home positions, kinematics module/type, and the joint↔axis map.
## Solid models (geometry)
A standard MTConnect twin viewer (e.g. the one at demo.mtconnect.org, or
TrakHound) renders a machine from `/probe` alone: geometry is **referenced, never
streamed**. The device model carries `<SolidModel href="...">` elements pointing
to external mesh files, plus `<CoordinateSystems>` and a `<Motion>` chain; the
viewer fetches each mesh once (from the agent's `/models/<name>`) and animates it
using the streamed positions. LinuxCNC bundles no viewer and no JavaScript — this
is plain, standards-compliant MTConnect.
### Zero-config geometry (`MODEL_AUTO`)
```ini
[MTCONNECT]
MODEL_AUTO = 1
```
The agent generates simple placeholder **box** meshes for the base and each axis
straight from the travel limits and serves them from memory — any machine gets a
functional twin with **no mesh files**. This is what the example config uses.
### Real geometry (per-link meshes)
Supply your own meshes (STL / OBJ / glTF) for fidelity:
```ini
[MTCONNECT]
MODEL_DIR = models ; base dir for relative paths (default: INI dir)
MODEL_BASE = frame.stl ; static frame / column (device-level SolidModel)
MODEL_X = x_table.stl ; link that moves with X
MODEL_Y = y_saddle.stl
MODEL_Z = spindle.stl
MODEL_SPINDLE = spindle.stl
```
Author each mesh in **millimetres** (MTConnect's canonical unit — the SolidModel
element carries no per-mesh unit) in the machine frame at the all-axes-zero pose.
An explicit
`MODEL_<axis>` overrides the generated box for that link, so files and
`MODEL_AUTO` can be mixed.
### Topology and direction (both modes)
These describe the mechanics and are **not** derivable from a trivkins INI:
```ini
MODEL_CHAIN = Y X Z ; nesting order of the moving links (base -> tip)
MODEL_PARENT_Z = BASE ; branch: Z (quill/tool) hangs off the base, not X/Y
MODEL_INVERT = X Y ; work-carrying links move opposite the reported coord
```
The agent then serves each mesh at `GET /models/<name>`, emits a device-level
`<SolidModel>` for the base and a per-axis `<SolidModel>` in each axis's
`<Configuration>`, and emits `<CoordinateSystems>` (WORLD → MACHINE) plus a
`<Motion>` chain (`parentIdRef`) so the twin nests transforms correctly.
### Viewing the twin
The agent serves the geometry but **bundles no viewer** — rendering is left to a
standard MTConnect client, so no third-party JavaScript lives in LinuxCNC. Two
paths:
- **A standard MTConnect twin viewer (recommended).** Point one at the agent and
it renders the machine from `/probe` + `/models/` with zero extra setup: the
demo viewer at <https://demo.mtconnect.org>, TrakHound, or cppagent's demo
twin. Expose the agent first with `[MTCONNECT]HTTP_BIND = 0.0.0.0` (it defaults
to loopback only) and give the viewer `http://<machine>:5000`.
- **Self-host a small viewer.** An earlier revision of this config shipped a
~12 KB three.js viewer (`twin.html`) that was removed from the tree to keep
third-party JS out of core. It still works against this agent — it fetches
`/probe` and `/current` and loads the `/models/` meshes. To run it yourself,
recover it from git history
(`git show <commit>:share/linuxcnc/mtconnect/twin.html`), repoint its three.js
import map at a CDN or a local `libjs-three`, and serve it same-origin as the
agent (e.g. behind a small reverse proxy, since the relative `fetch` calls
assume one origin).
## Using the official cppagent instead of the embedded agent
Put `shdr` in `TRANSPORT` and the agent acts as an SHDR adapter (default port
7878), streaming `<ts>|id|value` lines to an external cppagent. Configure that
cppagent with a `Devices.xml`, which the generated `/probe` document doubles as:
```sh
mtconnect-agent --dump-probe example.ini > Devices.xml
```
The `dataItemId`s in the SHDR stream match the ids in that `Devices.xml`.
## MQTT
With `mqtt` in `TRANSPORT` and `python3-paho-mqtt` installed, the agent publishes
the standard (vendor-neutral) MTConnect MQTT topics:
`<prefix>/Probe/<uuid>` (retained), `<prefix>/Current/<uuid>`,
`<prefix>/Sample/<uuid>`, `<prefix>/Asset/<uuid>/<assetId>`. A consumer discovers
the whole device from the retained Probe topic.
**Home Assistant** is not part of the core agent (its MQTT Discovery format is
HA-specific). An optional bridge, `contrib/mtconnect-ha-bridge`, reuses the
device model to publish HA discovery; configure it with arguments so no HA key
touches the machine INI:
```
loadusr -W mtconnect-ha-bridge --broker=[HA]BROKER --username=[HA]USER --password=[HA]PASSWORD
```
## Layout (installed)
```
src/hal/user_comps/mtconnect-agent.py entry point -> bin/mtconnect-agent
lib/python/mtc/ the agent package:
ini_reader.py INI access (linuxcnc.ini, with offline fallback)
kinematics.py build the kinematic model from the INI
observations.py shared DataItem registry (keeps probe and streams in sync)
device_model.py /probe MTConnectDevices document (+ CLI --dump-probe)
lcnc_source.py live status + tool table from linuxcnc.stat()
models.py solid-model config + served mesh registry (SolidModel geometry)
streams.py /current, /sample buffer + XML; /assets XML
agent.py transport-agnostic agent core (document builders)
http_agent.py embedded HTTP server
mqtt_agent.py optional MQTT publisher (vendor-neutral MTConnect binding)
share/linuxcnc/mtconnect/mtconnect-linuxcnc-1.xsd extension schema
configs/sim/axis/mtconnect/ this demo config + tests
contrib/mtconnect-ha-bridge opt-in Home Assistant MQTT-discovery bridge
contrib/ha.py HA discovery helper (used by the bridge)
```
## Testing
```sh
. scripts/rip-environment # so mtc is importable and bin/ is on PATH
python3 test_mtc.py # offline suite (no running LinuxCNC required)
python3 validate_schemas.py # validate the documents against the MTConnect XSDs
# (needs the 'xmlschema' package)
```
`test_mtc.py` covers kinematics mapping, probe↔registry consistency (3- and
5-axis), the observation buffer + streams, assets, the live HTTP endpoints, and
(when `xmlschema` is available) schema validation of all four documents.
## Custom HAL pins
Expose any HAL pin/signal as a data item with `HAL_ITEM` lines (read via
`hal.get_value`, no HAL wiring):
```ini
[MTCONNECT]
HAL_ITEM = pin=spindle.0.load, id=spindle_load, type=LOAD, units=PERCENT, component=spindle
HAL_ITEM = pin=hm2_5i25.temp, id=board_temp, type=TEMPERATURE, units=CELSIUS
```
Standard MTConnect SAMPLE types only (LOAD, TEMPERATURE, PRESSURE, VOLTAGE,
AMPERAGE, FREQUENCY, ANGLE, VELOCITY, TORQUE, …). `component=` hosts it under a
generic `Sensor` (default) or an existing component (spindle/controller/path/an
axis). Unsupported types are skipped with a warning. See `mtconnect-agent(1)`.
## Status
Complete and packaged for the build: the HTTP transport, HAL component and
schema-valid documents; the vendor-neutral MQTT binding plus the optional
Home Assistant bridge contrib; `SolidModel` geometry under `/models/` for any
standard MTConnect twin viewer; and the SHDR adapter for an external cppagent.
Realtime servo-rate data is explicitly out of scope.

View File

@@ -0,0 +1,94 @@
# Copyright (C) 2026 LinuxCNC contributors
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
# Home Assistant MQTT Discovery helper for the mtconnect-ha-bridge contrib.
#
# Home Assistant's MQTT Discovery is an HA-specific convention (its topic layout,
# JSON payload schema and Jinja value_template are defined by Home Assistant, not
# an open standard), so it is NOT part of the core mtconnect-agent. This helper
# builds, for the optional bridge:
# * retained discovery configs under <ha_prefix>/sensor/<node>/<key>/config so
# HA auto-creates a Device with one sensor per value (no YAML needed), and
# * a small flat JSON state document the sensors read via value_json.
import json
_LIN_UNIT = {"MILLIMETER": "mm", "INCH": "in", "CENTIMETER": "cm"}
def _lin(config):
return _LIN_UNIT.get(config.linear_units, "mm")
def build_sensors(model, config):
"""Curated, demo-friendly sensor set derived from the machine model."""
lin = _lin(config)
sensors = [
{"key": "execution", "name": "Execution", "icon": "mdi:cog-play"},
{"key": "mode", "name": "Mode", "icon": "mdi:tune"},
{"key": "estop", "name": "E-Stop", "icon": "mdi:alert-octagon"},
{"key": "program", "name": "Program", "icon": "mdi:file-document-outline"},
{"key": "toolnum", "name": "Tool", "icon": "mdi:screwdriver"},
{"key": "pathfeed", "name": "Feed rate", "unit": lin + "/s",
"icon": "mdi:speedometer", "num": True},
{"key": "spdl_speed", "name": "Spindle", "unit": "RPM",
"icon": "mdi:fan", "num": True},
]
for a in model.axes:
low = a.letter.lower()
icon = "mdi:axis-%s-arrow" % low if low in ("x", "y", "z") else "mdi:axis-arrow"
sensors.append({"key": "pos_%s" % low, "name": "%s position" % a.letter,
"unit": lin if a.kind == "LINEAR" else "°",
"icon": icon, "num": True})
return sensors
def node_id(config):
return "".join(c if (c.isalnum() or c in "-_") else "_" for c in config.uuid)
def discovery_payload(sensor, config, state_topic, avail_topic):
payload = {
"name": sensor["name"],
"unique_id": "%s_%s" % (config.uuid, sensor["key"]),
"state_topic": state_topic,
"value_template": "{{ value_json.%s }}" % sensor["key"],
"availability_topic": avail_topic,
"device": {
"identifiers": [config.uuid],
"name": config.name,
"manufacturer": "LinuxCNC",
"model": "MTConnect",
},
}
if sensor.get("icon"):
payload["icon"] = sensor["icon"]
if sensor.get("unit"):
payload["unit_of_measurement"] = sensor["unit"]
if sensor.get("num"):
payload["state_class"] = "measurement"
return payload
def state_json(values, sensors):
"""Flat JSON of the curated keys; skip missing/UNAVAILABLE/structured."""
out = {}
for s in sensors:
v = values.get(s["key"])
if v is None or v == "UNAVAILABLE" or isinstance(v, (dict, list)):
continue
out[s["key"]] = v
return json.dumps(out)

View File

@@ -0,0 +1,160 @@
#!/usr/bin/env python3
#
# Copyright (C) 2026 LinuxCNC contributors
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
#
# Home Assistant bridge for the LinuxCNC MTConnect agent (OPTIONAL CONTRIB).
#
# Publishes LinuxCNC machine state as Home Assistant MQTT Discovery, reusing the
# MTConnect agent's auto-generated device model. This is deliberately NOT part
# of the core mtconnect-agent: Home Assistant's discovery format is HA-specific,
# so it lives here as an opt-in. The core agent publishes only the vendor-neutral
# MTConnect MQTT binding.
#
# All Home Assistant / broker settings are passed as arguments, so no HA-specific
# key is needed in the machine INI. Load it from a HAL file alongside the agent,
# wiring the broker details from any INI section you like:
#
# loadusr -W mtconnect-ha-bridge \
# --broker=[HA]BROKER --username=[HA]USER --password=[HA]PASSWORD
#
# The machine INI comes from $INI_FILE_NAME (only the device model is read from
# it; nothing HA-specific).
import argparse
import json
import os
import signal
import sys
import time
# Local HA helper (ha.py in this directory) + the installed mtc package.
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
_EMC2_HOME = os.environ.get("EMC2_HOME")
if _EMC2_HOME:
sys.path.insert(0, os.path.join(_EMC2_HOME, "lib", "python"))
import ha as ha_mod # noqa: E402
from mtc.agent import AgentState # noqa: E402
def make_hal_pins():
"""Minimal HAL component so 'loadusr -W' can wait for us; else None."""
try:
import hal
except ImportError:
return None
comp = hal.component("mtconnect-ha-bridge")
comp.newpin("enable", hal.Type.BOOL, hal.Dir.IN)
comp["enable"] = True
comp.newpin("connected", hal.Type.BOOL, hal.Dir.OUT)
comp.ready()
return comp
def main():
p = argparse.ArgumentParser(description="Home Assistant bridge for the "
"LinuxCNC MTConnect agent")
p.add_argument("ini", nargs="?", default=os.environ.get("INI_FILE_NAME"),
help="LinuxCNC INI file (default: $INI_FILE_NAME)")
p.add_argument("--broker", default="localhost")
p.add_argument("--port", type=int, default=1883)
p.add_argument("--username")
p.add_argument("--password")
p.add_argument("--ha-prefix", default="homeassistant",
help="Home Assistant discovery prefix (default homeassistant)")
p.add_argument("--mqtt-prefix", default="MTConnect",
help="namespace for the bridge state/availability topics")
p.add_argument("--sample-hz", type=float, default=2.0)
args = p.parse_args()
if not args.ini:
p.error("no INI file (set INI_FILE_NAME or pass one)")
try:
import paho.mqtt.client as mqtt
except ModuleNotFoundError:
print("error: Missing Python module paho.mqtt "
"(Debian: 'sudo apt install python3-paho-mqtt').")
return 2
state = AgentState(args.ini)
sensors = ha_mod.build_sensors(state.model, state.config)
uuid = state.config.uuid
prefix = args.mqtt_prefix.rstrip("/")
state_topic = "%s/ha/%s/state" % (prefix, uuid)
avail_topic = "%s/ha/%s/availability" % (prefix, uuid)
node = ha_mod.node_id(state.config)
comp = make_hal_pins()
stop = {"v": False}
signal.signal(signal.SIGTERM, lambda *a: stop.__setitem__("v", True))
signal.signal(signal.SIGINT, lambda *a: stop.__setitem__("v", True))
def on_connect(client, userdata, flags, rc, *a):
if comp is not None:
comp["connected"] = (rc == 0)
if rc == 0:
for s in sensors:
topic = "%s/sensor/%s/%s/config" % (args.ha_prefix, node, s["key"])
payload = ha_mod.discovery_payload(s, state.config,
state_topic, avail_topic)
client.publish(topic, json.dumps(payload), retain=True)
client.publish(avail_topic, "online", retain=True)
print("info: HA discovery published under %s/sensor/%s/*"
% (args.ha_prefix, node))
else:
print("error: MQTT connect failed (rc=%s)" % rc)
def on_disconnect(client, userdata, rc, *a):
if comp is not None:
comp["connected"] = False
try:
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION1,
client_id="linuxcnc-mtconnect-ha")
except AttributeError:
client = mqtt.Client(client_id="linuxcnc-mtconnect-ha")
if args.username:
client.username_pw_set(args.username, args.password)
client.will_set(avail_topic, "offline", retain=True)
client.on_connect = on_connect
client.on_disconnect = on_disconnect
client.connect_async(args.broker, args.port, keepalive=60)
client.loop_start()
print("info: mtconnect-ha-bridge -> %s:%d (HA prefix '%s')"
% (args.broker, args.port, args.ha_prefix))
try:
while not stop["v"]:
if comp is not None and not comp["enable"]:
time.sleep(0.2)
continue
state.poll_once()
client.publish(state_topic,
ha_mod.state_json(state.latest_values(), sensors),
retain=True)
time.sleep(1.0 / max(args.sample_hz, 0.1))
except KeyboardInterrupt:
pass
finally:
client.publish(avail_topic, "offline", retain=True)
client.loop_stop()
client.disconnect()
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,200 @@
# Simulated 3-axis mill demonstrating first-class MTConnect support.
#
# This is the standard sim/axis machine plus an [MTCONNECT] section and an
# [APPLICATIONS] entry that launches the MTConnect agent after the GUI starts.
# The core HAL files (core_sim.hal, ...) are found in the system HALLIB; only
# cooling.hal is local to the sibling sim/axis directory.
#
# Run: linuxcnc example.ini
# Then: curl http://localhost:5000/probe
[EMC]
VERSION = 1.1
MACHINE = LinuxCNC-MTConnect-Demo
DEBUG = 0
[DISPLAY]
DISPLAY = axis
CYCLE_TIME = 0.100
POSITION_OFFSET = RELATIVE
POSITION_FEEDBACK = ACTUAL
MAX_FEED_OVERRIDE = 1.2
MAX_SPINDLE_OVERRIDE = 1.0
MAX_LINEAR_VELOCITY = 5
DEFAULT_LINEAR_VELOCITY = .25
DEFAULT_SPINDLE_SPEED = 200
PROGRAM_PREFIX = ../../../nc_files/
INTRO_GRAPHIC = linuxcnc.gif
INTRO_TIME = 5
TOOL_EDITOR = tooledit
# MTConnect feature -----------------------------------------------------------
# A few lines enable the whole feature. The device model (/probe) is generated
# automatically from the sections below.
[MTCONNECT]
ENABLE = 1
DEVICE_NAME = mtconnect_demo
UUID = linuxcnc-demo-0001
HTTP_PORT = 5000
# HTTP_BIND: interface to serve on. Default 127.0.0.1 (loopback only); set to
# 0.0.0.0 to expose the agent on the LAN. (INI values take no inline comments.)
# HTTP_BIND = 127.0.0.1
# TRANSPORT: comma-separated list of http, mqtt, shdr (legacy "both" = http,mqtt)
TRANSPORT = http
SAMPLE_HZ = 10
# MQTT_BROKER = localhost
# MQTT_PORT = 1883
# MQTT_PREFIX = MTConnect
# Custom HAL pins as MTConnect data items. Each HAL_ITEM reads a HAL pin/signal
# (via hal.get_value) into a standard MTConnect SAMPLE data item. component=
# names the host (default: a generic Sensor; or spindle/controller/path/an axis).
# Only standard MTConnect SAMPLE types are accepted (e.g. LOAD, TEMPERATURE,
# PRESSURE, VOLTAGE, AMPERAGE, FREQUENCY, ANGLE, VELOCITY, TORQUE, ...).
# HAL_ITEM = pin=spindle.0.load, id=spindle_load, type=LOAD, units=PERCENT, component=spindle
# HAL_ITEM = pin=hm2_5i25.temp, id=board_temp, type=TEMPERATURE, units=CELSIUS
# Solid-model geometry. The /probe document references meshes with SolidModel
# elements and the agent serves them from /models/, so a standard MTConnect twin
# viewer (e.g. demo.mtconnect.org, TrakHound) can render the machine. LinuxCNC
# bundles no viewer. MODEL_AUTO generates simple placeholder box geometry for the
# base and each axis from the travel limits, so it works with no mesh files. To
# use real geometry instead, drop STL/OBJ/glTF files in models/ and set
# MODEL_BASE / MODEL_X / MODEL_Y / MODEL_Z. Meshes must be authored in
# millimetres (MTConnect's canonical unit; SolidModel carries no per-mesh unit).
MODEL_AUTO = 1
# Nesting order of the moving links.
MODEL_CHAIN = Y X Z
# Branched topology: the quill/tool (Z) hangs off the base, not the table.
MODEL_PARENT_Z = BASE
# Table/saddle physically move opposite the reported (tool-relative) coordinate.
MODEL_INVERT = X Y
[TASK]
TASK = milltask
CYCLE_TIME = 0.001
[RS274NGC]
PARAMETER_FILE = sim.var
[EMCMOT]
EMCMOT = motmod
COMM_TIMEOUT = 1.0
BASE_PERIOD = 0
SERVO_PERIOD = 1000000
[EMCIO]
TOOL_TABLE = ../sim.tbl
TOOL_CHANGE_POSITION = 0 0 0
TOOL_CHANGE_QUILL_UP = 1
[HAL]
HALFILE = core_sim.hal
HALFILE = sim_spindle_encoder.hal
HALFILE = axis_manualtoolchange.hal
HALFILE = simulated_home.hal
HALFILE = check_xyz_constraints.hal
HALFILE = ../cooling.hal
# Load the MTConnect agent as a userspace HAL component (see mtconnect.hal).
HALFILE = mtconnect.hal
HALUI = halui
[TRAJ]
COORDINATES = X Y Z
LINEAR_UNITS = inch
ANGULAR_UNITS = degree
MAX_LINEAR_VELOCITY = 4
DEFAULT_LINEAR_ACCELERATION = 100
MAX_LINEAR_ACCELERATION = 100
POSITION_FILE = position.txt
[KINS]
KINEMATICS = trivkins
JOINTS = 3
# Spindle 0 usable speed band (RPM) for a small VFD-driven knee mill. The agent
# maps the forward limits into /probe as both a spindle
# <Specification type="ROTARY_VELOCITY"> (Maximum/Minimum) under the Rotary "S"
# component and <Constraints> on the commanded-velocity DataItem.
[SPINDLE_0]
MIN_FORWARD_VELOCITY = 100
MAX_FORWARD_VELOCITY = 6000
# Reverse band + step size: not read by the agent, included for a realistic
# machine model (MAX_REVERSE defaults to MAX_FORWARD if omitted).
MAX_REVERSE_VELOCITY = 6000
INCREMENT = 100
[AXIS_X]
MAX_VELOCITY = 4
MAX_ACCELERATION = 100.0
MIN_LIMIT = -10.0
MAX_LIMIT = 10.0
[AXIS_Y]
MAX_VELOCITY = 4
MAX_ACCELERATION = 100.0
MIN_LIMIT = -10.0
MAX_LIMIT = 10.0
[AXIS_Z]
MAX_VELOCITY = 4
MAX_ACCELERATION = 100.0
MIN_LIMIT = -8.0
MAX_LIMIT = 0.12
[JOINT_0]
TYPE = LINEAR
HOME = 0.000
MAX_VELOCITY = 5
MAX_ACCELERATION = 50.0
BACKLASH = 0.000
INPUT_SCALE = 4000
OUTPUT_SCALE = 1.000
FERROR = 0.050
MIN_FERROR = 0.010
MIN_LIMIT = -10.0
MAX_LIMIT = 10.0
HOME_OFFSET = 0.0
HOME_SEARCH_VEL = 20.0
HOME_LATCH_VEL = 20.0
HOME_USE_INDEX = NO
HOME_IGNORE_LIMITS = NO
HOME_SEQUENCE = 1
[JOINT_1]
TYPE = LINEAR
HOME = 0.000
MAX_VELOCITY = 5
MAX_ACCELERATION = 50.0
BACKLASH = 0.000
INPUT_SCALE = 4000
OUTPUT_SCALE = 1.000
FERROR = 0.050
MIN_FERROR = 0.010
MIN_LIMIT = -10.0
MAX_LIMIT = 10.0
HOME_OFFSET = 0.0
HOME_SEARCH_VEL = 20.0
HOME_LATCH_VEL = 20.0
HOME_USE_INDEX = NO
HOME_IGNORE_LIMITS = NO
HOME_SEQUENCE = 1
[JOINT_2]
TYPE = LINEAR
HOME = 0.0
MAX_VELOCITY = 5
MAX_ACCELERATION = 50.0
BACKLASH = 0.000
INPUT_SCALE = 4000
OUTPUT_SCALE = 1.000
MIN_LIMIT = -8.0
MAX_LIMIT = 0.12
FERROR = 0.050
MIN_FERROR = 0.010
HOME_OFFSET = 1.0
HOME_SEARCH_VEL = 20.0
HOME_LATCH_VEL = 20.0
HOME_USE_INDEX = NO
HOME_IGNORE_LIMITS = NO
HOME_SEQUENCE = 0

View File

@@ -0,0 +1,16 @@
# Load the MTConnect agent as a userspace HAL component.
#
# -W waits until the component ("mtconnect-agent") is ready before HAL
# processing continues, so its pins exist for any links below. The machine INI
# is taken from $INI_FILE_NAME (set by LinuxCNC), so no argument is needed.
#
# Once loaded the component exposes:
# in : mtconnect-agent.enable (bit) gate polling/serving
# mtconnect-agent.sample-hz (u32) override the INI SAMPLE_HZ
# out: mtconnect-agent.active (bit) polling + serving
# mtconnect-agent.connected (bit) MQTT broker link up
# mtconnect-agent.heartbeat (u32) increments each poll
loadusr -W mtconnect-agent
# Example: drive a panel lamp from agent state (uncomment + wire to your HAL):
# net mtc-up mtconnect-agent.active => some-lamp.in

View File

@@ -0,0 +1,639 @@
#!/usr/bin/env python3
# Offline tests for the MTConnect agent prototype.
#
# Exercises the probe generator, the probe<->registry consistency, the
# observation buffer + streams, and the asset builder without a running
# LinuxCNC. Run: python3 test_mtc.py
import os
import socket
import subprocess
import sys
import time
import urllib.request
import xml.etree.ElementTree as ET
# The mtc package installs to $EMC2_HOME/lib/python; make it importable when this
# is run from the config directory (rip-environment already puts it on the path).
_EMC2_HOME = os.environ.get("EMC2_HOME")
if _EMC2_HOME:
sys.path.insert(0, os.path.join(_EMC2_HOME, "lib", "python"))
from mtc.ini_reader import IniReader
from mtc import kinematics as kin
from mtc.device_model import DeviceConfig, probe_xml, MTC_NS, EXT_NS
from mtc.observations import build_dataitems
from mtc.streams import (ObservationBuffer, current_xml, sample_xml, assets_xml,
STREAMS_NS)
from mtc.lcnc_source import ToolAsset, LcncSource
from mtc.agent import AgentState
from mtc.http_agent import HttpAgent
from mtc.shdr_agent import ShdrAgent
# Resolve config paths relative to this file, not the current directory, so the
# suite runs from any CWD (e.g. when wired into tests/ and driven by runtests).
_HERE = os.path.dirname(os.path.abspath(__file__))
def _cfg(relpath):
return os.path.normpath(os.path.join(_HERE, relpath))
CONFIGS = {
"3axis": _cfg("../axis.ini"),
"5axis": _cfg("../vismach/5axis/table-rotary-tilting/xyzac-trt.ini"),
}
TS = "2026-07-01T12:00:00.000Z"
def load(path):
ini = IniReader(path)
model = kin.build_model(ini)
config = DeviceConfig.from_ini(ini)
return ini, model, config
def test_kinematics():
_, model, _ = load(CONFIGS["5axis"])
assert model.kins_module == "xyzac-trt-kins", model.kins_module
assert model.coordinates == "XYZAC", model.coordinates
assert model.joints_count == 5
jmap = model.joint_axis_map()
assert jmap == {0: "X", 1: "Y", 2: "Z", 3: "A", 4: "C"}, jmap
kinds = {a.letter: a.kind for a in model.axes}
assert kinds["A"] == "ANGULAR" and kinds["X"] == "LINEAR", kinds
# A rotates about X, C about Z
vecs = {a.letter: a.vector for a in model.axes}
assert vecs["A"] == (1.0, 0.0, 0.0) and vecs["C"] == (0.0, 0.0, 1.0), vecs
print("ok kinematics (5-axis XYZAC mapping + rotary vectors)")
def test_probe_registry_consistency():
for label, path in CONFIGS.items():
_, model, config = load(path)
xml = probe_xml(model, config, creation_time=TS)
root = ET.fromstring(xml)
probe_ids = {}
for di in root.iter("{%s}DataItem" % MTC_NS):
probe_ids[di.get("id")] = (di.get("category"), di.get("type"),
di.get("subType"))
for d in build_dataitems(model, config):
assert d.id in probe_ids, "%s: %s missing from probe" % (label, d.id)
cat, typ, sub = probe_ids[d.id]
expected_type = ("x:" + d.type) if d.ext else d.type
assert (cat, typ, sub) == (d.category, expected_type, d.subType), \
"%s: %s attrs %s != %s" % (label, d.id, (cat, typ, sub),
(d.category, d.type, d.subType))
# kinematics extension present
kext = root.find(".//{%s}Kinematics" % EXT_NS)
assert kext is not None, "%s: no kinematics extension" % label
assert kext.get("module") == model.kins_module
print("ok probe/registry consistency (%s, %d DataItems)"
% (label, len(probe_ids)))
def test_buffer_and_streams():
_, model, config = load(CONFIGS["3axis"])
dataitems = build_dataitems(model, config)
buf = ObservationBuffer(dataitems)
n = buf.ingest({"execution": "READY", "pos_x": 1.0, "pos_y": 2.0}, TS)
assert n == 3, n
assert buf.ingest({"pos_x": 1.0}, TS) == 0, "unchanged value must not record"
assert buf.ingest({"pos_x": 5.0}, TS) == 1
assert buf.last_sequence == 4, buf.last_sequence
# /current: every DataItem present, unset ones UNAVAILABLE
cur = ET.fromstring(current_xml(buf, config, TS))
observed = {e.get("dataItemId"): e.text
for e in cur.iter() if e.get("dataItemId")}
assert observed["pos_x"] == "5.0", observed.get("pos_x")
assert observed["pos_z"] == "UNAVAILABLE", observed.get("pos_z")
assert observed["execution"] == "READY"
# /sample: sequence range 1..2 returns exactly the first two observations
smp = ET.fromstring(sample_xml(buf, config, TS, from_seq=1, count=2))
seqs = sorted(int(e.get("sequence")) for e in smp.iter()
if e.get("sequence"))
assert seqs == [1, 2], seqs
# ComponentStream grouping + Samples/Events wrappers exist
assert cur.find(".//{%s}ComponentStream" % STREAMS_NS) is not None
print("ok buffer + current/sample streams")
def test_work_offset_table():
_, model, config = load(CONFIGS["3axis"])
buf = ObservationBuffer(build_dataitems(model, config))
buf.ingest({"workoffset": {"G55": {"X": 1.5, "Y": -2.0, "Z": 0.0}}}, TS)
cur = ET.fromstring(current_xml(buf, config, TS))
# WORK_OFFSET is a standard EVENT type; TABLE representation streams as
# <WorkOffsetTable> (not <WorkOffset>).
wo = cur.find(".//{%s}WorkOffsetTable" % STREAMS_NS)
assert wo is not None and wo.get("count") == "1", wo
entry = wo.find("{%s}Entry" % STREAMS_NS)
assert entry.get("key") == "G55", entry.attrib
cells = {c.get("key"): c.text for c in entry.findall("{%s}Cell" % STREAMS_NS)}
assert cells == {"X": "1.5", "Y": "-2", "Z": "0"}, cells
# probe advertises it as a standard WORK_OFFSET EVENT with TABLE representation
probe = ET.fromstring(probe_xml(model, config))
di = [d for d in probe.iter("{%s}DataItem" % MTC_NS)
if d.get("id") == "workoffset"][0]
assert di.get("representation") == "TABLE" and di.get("type") == "WORK_OFFSET"
assert di.get("category") == "EVENT", di.get("category")
print("ok work offset TABLE (WorkOffsetTable Entry/Cell + probe representation)")
def test_tool_offset_and_rotation():
_, model, config = load(CONFIGS["3axis"])
buf = ObservationBuffer(build_dataitems(model, config))
buf.ingest({"tooloffset": {"T3": {"X": 0.0, "Y": 0.0, "Z": 2.5}},
"xyrotation": 30.0}, TS)
cur = ET.fromstring(current_xml(buf, config, TS))
# TOOL_OFFSET is a standard EVENT type -> <ToolOffsetTable> in STREAMS_NS.
to = cur.find(".//{%s}ToolOffsetTable" % STREAMS_NS)
assert to is not None, "no ToolOffsetTable in stream"
entry = to.find("{%s}Entry" % STREAMS_NS)
assert entry.get("key") == "T3"
zc = [c for c in entry.findall("{%s}Cell" % STREAMS_NS) if c.get("key") == "Z"][0]
assert zc.text == "2.5", zc.text
# xyrotation has no standard MTConnect type, so it stays a LinuxCNC extension.
rot = cur.find(".//{%s}CoordinateRotation" % EXT_NS)
assert rot is not None and rot.text == "30.0", rot
probe = ET.fromstring(probe_xml(model, config))
types = {d.get("id"): d.get("type") for d in probe.iter("{%s}DataItem" % MTC_NS)}
assert types["tooloffset"] == "TOOL_OFFSET", types.get("tooloffset") # standard now
assert types["xyrotation"] == "x:COORDINATE_ROTATION", types.get("xyrotation")
print("ok tool offset (standard ToolOffsetTable) + XY rotation (x: extension)")
def test_assets():
_, _, config = load(CONFIGS["3axis"])
tools = [
ToolAsset(tool_no=1, pocket=1, in_spindle=True, diameter=6.35, length_z=50.0,
comment="1/4 flat endmill "), # trailing space should be trimmed
ToolAsset(tool_no=2, pocket=5, in_spindle=False, diameter=3.0),
]
root = ET.fromstring(assets_xml(tools, config, TS))
ns = "{urn:mtconnect.org:MTConnectAssets:1.7}"
cts = root.findall(".//%sCuttingTool" % ns)
assert len(cts) == 2, len(cts)
assert cts[0].get("assetId") == "tool-1"
assert cts[0].get("serialNumber") == "tool-1", cts[0].attrib # required attr
loc = cts[0].find(".//%sLocation" % ns)
assert loc.get("type") == "SPINDLE" and loc.text == "1", loc.attrib
desc = cts[0].find("%sDescription" % ns)
assert desc is not None and desc.text == "1/4 flat endmill", repr(desc.text)
assert cts[1].find("%sDescription" % ns) is None # empty comment -> no element
# length offset is FunctionalLength (LF), not BodyLengthMax
lf = cts[0].find(".//%sFunctionalLength" % ns)
assert lf is not None and lf.get("code") == "LF" and lf.text == "50", ET.tostring(cts[0])
assert cts[0].find(".//%sBodyLengthMax" % ns) is None
# diameter is a CuttingItem measurement, not a tool-level one
dia = cts[0].find(".//%sCuttingItems/%sCuttingItem/%sMeasurements/%sCuttingDiameter"
% (ns, ns, ns, ns))
assert dia is not None and dia.text == "6.35" and dia.get("code") == "DC", ET.tostring(cts[0])
print("ok assets (serialNumber + FunctionalLength + CuttingItem diameter + comment)")
def test_probe_standard_blocks():
# spindle Specifications, Coolant system, and the kinematics extension living
# inside the (schema-valid) Description host.
_, model, config = load(_cfg("example.ini"))
root = ET.fromstring(probe_xml(model, config, creation_time=TS))
ns = "{%s}" % MTC_NS
spec = root.find(".//%sRotary[@id='spindle']//%sSpecification" % (ns, ns))
assert spec is not None and spec.get("type") == "ROTARY_VELOCITY", "no spindle spec"
assert spec.find("%sMaximum" % ns).text == "6000"
cool = root.find(".//%sCoolant" % ns)
assert cool is not None, "no Coolant component"
assert cool.find(".//%sDataItem[@id='coolant_flood']" % ns).get("type") == "x:FLOOD"
# x:Kinematics is nested in a Description (the lax xs:any extension point)
kin_ext = root.find(".//%sDescription/{%s}Kinematics" % (ns, EXT_NS))
assert kin_ext is not None, "x:Kinematics not under Description"
assert kin_ext.get("nativeLinearUnits") == "INCH"
# Agent device is present (schema-required alongside the machine Device)
assert root.find(".//%sAgent" % ns) is not None, "no Agent device"
print("ok probe standard blocks (spindle Spec, Coolant, kinematics host, Agent)")
def test_spindle_constraints():
ns = "{%s}" % MTC_NS
def spdl_cmd(path):
_, model, config = load(path)
root = ET.fromstring(probe_xml(model, config, creation_time=TS))
for di in root.iter("%sDataItem" % ns):
if di.get("id") == "spdl_speed_cmd":
return di
raise AssertionError("spdl_speed_cmd missing")
# example.ini declares [SPINDLE_0] MIN/MAX_FORWARD_VELOCITY -> Constraints
di = spdl_cmd(_cfg("example.ini"))
con = di.find("%sConstraints" % ns)
assert con is not None, "no Constraints on spdl_speed_cmd"
assert con.find("%sMinimum" % ns).text == "100", ET.tostring(di)
assert con.find("%sMaximum" % ns).text == "6000", ET.tostring(di)
# a config without spindle velocity limits emits no Constraints (no bogus
# ~2.1e9 default range fabricated).
assert spdl_cmd(CONFIGS["3axis"]).find("%sConstraints" % ns) is None
print("ok spindle speed range Constraints (from [SPINDLE_0], omitted when absent)")
def test_schema_validation():
# Gated: needs the xmlschema package (XSD 1.1) and the official 1.7 XSDs
# (local dir via $MTC_XSD_DIR, else fetched). Skips cleanly otherwise so
# the core suite stays dependency- and network-free.
import os
try:
import io
import xmlschema
except ImportError:
print("skip schema validation (xmlschema not installed)")
return
from mtc.agent import asset_dir
xsd_dir = os.environ.get("MTC_XSD_DIR")
base = (lambda n: os.path.join(xsd_dir, n)) if xsd_dir else \
(lambda n: "http://schemas.mtconnect.org/schemas/" + n)
ext = os.path.join(asset_dir(), "mtconnect-linuxcnc-1.xsd")
state = AgentState(_cfg("example.ini"))
state.poll_once()
checks = [("MTConnectDevices_1.7.xsd", state.probe_document()),
("MTConnectAssets_1.7.xsd", state.assets_document()),
(ext, state.current_document())]
try:
for xsd, doc in checks:
loc = xsd if xsd == ext else base(xsd)
schema = xmlschema.XMLSchema11(loc, validation="skip")
errs = list(schema.iter_errors(io.StringIO(doc)))
assert not errs, "%s: %s" % (xsd, (errs[0].reason or errs[0].message))
except (OSError, xmlschema.XMLSchemaException) as exc:
if xsd_dir:
raise
print("skip schema validation (schemas unreachable: %s)" % exc)
return
print("ok schema validation (probe/assets base, current base+extension)")
def test_source_offline():
_, model, config = load(CONFIGS["3axis"])
src = LcncSource(model, config)
# No linuxcnc extension / no running instance -> graceful empty results.
if not src.available():
assert src.sample_values() == {}
assert src.tool_assets() == []
print("ok lcnc_source offline (graceful, no stat)")
else:
print("ok lcnc_source live (stat available)")
def test_http_endpoints():
state = AgentState(CONFIGS["3axis"])
state.buffer.ingest({"execution": "ACTIVE", "pos_x": 3.5}, TS)
state._assets = [ToolAsset(tool_no=7, pocket=7, in_spindle=True, diameter=10.0)]
http = HttpAgent(state, host="127.0.0.1", port=0)
http.start()
try:
base = "http://127.0.0.1:%d" % http.port
def get(path):
with urllib.request.urlopen(base + path, timeout=5) as r:
return r.status, r.read().decode()
st, body = get("/probe")
assert st == 200 and "MTConnectDevices" in body, st
st, body = get("/current")
assert st == 200 and "ACTIVE" in body, body[:200]
st, body = get("/sample?from=1&count=10")
assert st == 200 and 'sequence="1"' in body, body[:200]
st, body = get("/assets")
assert st == 200 and "tool-7" in body, body[:200]
# Bad route and out-of-range sequence return MTConnectError docs.
try:
get("/nope")
assert False, "expected 404"
except urllib.error.HTTPError as e:
assert e.code == 404 and "MTConnectError" in e.read().decode()
try:
get("/sample?from=999999")
assert False, "expected 406"
except urllib.error.HTTPError as e:
assert e.code == 406 and "OUT_OF_RANGE" in e.read().decode()
print("ok embedded HTTP agent (probe/current/sample/assets + errors)")
finally:
http.stop()
def _is_iso_ts(field):
# e.g. 2026-07-28T12:00:00.000Z -- cheap structural check (no regex import).
return (len(field) >= 20 and field[4] == "-" and field[7] == "-"
and field[10] == "T" and field.endswith("Z"))
def test_shdr():
# Offline SHDR adapter test: no LinuxCNC, values injected via _last_values
# (what AgentState.latest_values() returns), which is the poll snapshot the
# adapter diffs and streams.
state = AgentState(CONFIGS["3axis"])
shdr = ShdrAgent(state, port=0, host="127.0.0.1")
shdr.start()
client = None
try:
client = socket.create_connection(("127.0.0.1", shdr.port), timeout=2)
client.settimeout(2)
# Wait until the server has registered the client (its handler thread ran
# _add_client) so the broadcast below is guaranteed to reach us.
deadline = time.time() + 2
while time.time() < deadline:
with shdr._lock:
registered = len(shdr._clients)
if registered:
break
time.sleep(0.01)
assert registered == 1, registered
buf = [b""]
def recv_line():
while b"\n" not in buf[0]:
chunk = client.recv(4096)
if not chunk:
raise AssertionError("connection closed before a full line")
buf[0] += chunk
line, _, rest = buf[0].partition(b"\n")
buf[0] = rest
return line.decode()
# (a) ingest/poll some values, then stream the changes. A structured
# (TABLE) value must be skipped, never emitted as a malformed line.
state._last_values = {"execution": "ACTIVE", "pos_x": 3.5,
"workoffset": {"G54": {"X": 1.0}}}
shdr.publish_changes()
line = recv_line()
fields = line.split("|")
assert len(fields) >= 3, "not a well-formed SHDR line: %r" % line
assert _is_iso_ts(fields[0]), "first field not ISO ts: %r" % fields[0]
ids = fields[1::2]
assert "execution" in ids and "pos_x" in ids, ids
assert "workoffset" not in line, "TABLE value must be skipped: %r" % line
# unchanged values produce nothing; a real change is streamed
shdr.publish_changes()
state._last_values = {"execution": "READY", "pos_x": 3.5,
"workoffset": {"G54": {"X": 1.0}}}
shdr.publish_changes()
line2 = recv_line()
f2 = line2.split("|")
assert _is_iso_ts(f2[0]) and "execution" in f2[1::2], line2
assert "READY" in line2, line2
# (b) heartbeat: '* PING' -> '* PONG 10000'
client.sendall(b"* PING\n")
pong = recv_line()
assert pong == "* PONG 10000", repr(pong)
print("ok SHDR adapter (initial/changed ts|id|value lines, TABLE skipped, PING/PONG)")
finally:
if client is not None:
client.close()
shdr.stop()
def test_hal_items():
import tempfile
from mtc.hal_items import HalSource
ini_text = (
"[EMC]\nMACHINE = hal-item-demo\n"
"[TRAJ]\nCOORDINATES = X Y Z\nLINEAR_UNITS = inch\n"
"[KINS]\nKINEMATICS = trivkins\nJOINTS = 3\n"
"[AXIS_X]\nMIN_LIMIT = -10\nMAX_LIMIT = 10\n"
"[AXIS_Y]\nMIN_LIMIT = -6\nMAX_LIMIT = 6\n"
"[AXIS_Z]\nMIN_LIMIT = -8\nMAX_LIMIT = 0\n"
"[MTCONNECT]\n"
"HAL_ITEM = pin=spindle.0.load, id=spindle_load, type=LOAD, units=PERCENT, component=spindle\n"
"HAL_ITEM = pin=hm2.temp, id=board_temp, type=TEMPERATURE, units=CELSIUS\n"
"HAL_ITEM = pin=bad.one, id=bad_custom, type=WIDGET_COUNT\n" # rejected
"HAL_ITEM = pin=x, id=1nope, type=LOAD\n" # bad id, rejected
)
d = tempfile.mkdtemp()
p = os.path.join(d, "hal.ini")
with open(p, "w") as fh:
fh.write(ini_text)
ini = IniReader(p)
model = kin.build_model(ini)
config = DeviceConfig.from_ini(ini)
# only the two valid items survive parsing
assert [i.id for i in config.hal_items] == ["spindle_load", "board_temp"], \
[i.id for i in config.hal_items]
# they appear in the registry as SAMPLE items of the right type
reg = {d.id: d for d in build_dataitems(model, config)}
assert reg["spindle_load"].type == "LOAD" and reg["spindle_load"].category == "SAMPLE"
assert reg["spindle_load"].comp_id == "spindle" # targeted the spindle
assert reg["board_temp"].comp_id == "sensors" # default Sensor host
# probe: generic item under Auxiliaries/Sensor, targeted item under the spindle
probe = ET.fromstring(probe_xml(model, config))
ns = "{%s}" % MTC_NS
sensor = probe.find(".//%sAuxiliaries/%sComponents/%sSensor" % (ns, ns, ns))
assert sensor is not None, "no Auxiliaries/Sensor"
sensor_ids = {di.get("id") for di in sensor.iter("%sDataItem" % ns)}
assert "board_temp" in sensor_ids and "spindle_load" not in sensor_ids, sensor_ids
sp = probe.find(".//%sRotary[@id='spindle']" % ns)
assert "spindle_load" in {di.get("id") for di in sp.iter("%sDataItem" % ns)}
# offline HalSource (no hal extension) yields nothing, gracefully
assert HalSource(config.hal_items).sample_values() == {}
print("ok HAL items (parse/validate, placement, offline HalSource)")
def test_solid_models():
import os
import tempfile
from mtc.models import MachineModels, MeshRef
_, model, config = load(CONFIGS["3axis"])
# No models configured -> no SolidModel / Configuration emitted.
plain = ET.fromstring(probe_xml(model, config))
assert plain.find(".//{%s}SolidModel" % MTC_NS) is None
tmp = tempfile.mkdtemp()
path = os.path.join(tmp, "z_head.stl")
with open(path, "wb") as fh:
fh.write(b"solid dummy\nendsolid dummy\n")
ref = MeshRef("z_head.stl", path, "STL", "model/stl", True)
mm = MachineModels(units="MILLIMETER", base=ref, axis={"Z": ref},
chain=["dev_base", "axis_x", "axis_y", "axis_z"],
parents={"axis_x": "dev_base", "axis_y": "axis_x",
"axis_z": "axis_y"},
served={"z_head.stl": ref})
probe = ET.fromstring(probe_xml(model, config, models=mm))
sms = probe.findall(".//{%s}SolidModel" % MTC_NS)
hrefs = {sm.get("href") for sm in sms}
assert "/models/z_head.stl" in hrefs, hrefs
assert probe.find(".//{%s}CoordinateSystem[@type='MACHINE']" % MTC_NS) is not None
# serial chain: Z link hangs off Y's motion
mz = [m for m in probe.iter("{%s}Motion" % MTC_NS) if m.get("id") == "motion_z"][0]
assert mz.get("parentIdRef") == "motion_y", mz.attrib
# branched: re-root Z at the base (knee-mill quill) -> no parentIdRef
mm.parents["axis_z"] = "dev_base"
probe2 = ET.fromstring(probe_xml(model, config, models=mm))
mz2 = [m for m in probe2.iter("{%s}Motion" % MTC_NS) if m.get("id") == "motion_z"][0]
assert mz2.get("parentIdRef") is None, mz2.attrib
# inverted axis: Motion <Axis> vector is negated (work-carrying link)
mm.invert = {"Z"}
probe3 = ET.fromstring(probe_xml(model, config, models=mm))
mz3 = [m for m in probe3.iter("{%s}Motion" % MTC_NS) if m.get("id") == "motion_z"][0]
assert mz3.find("{%s}Axis" % MTC_NS).text == "0 0 -1", mz3.find("{%s}Axis" % MTC_NS).text
# /models route serves the file; unknown model -> 404
state = AgentState(CONFIGS["3axis"])
state.models = mm
http = HttpAgent(state, host="127.0.0.1", port=0)
http.start()
try:
with urllib.request.urlopen(
"http://127.0.0.1:%d/models/z_head.stl" % http.port, timeout=5) as r:
assert r.status == 200 and b"endsolid" in r.read()
try:
urllib.request.urlopen("http://127.0.0.1:%d/models/nope.stl" % http.port)
assert False, "expected 404"
except urllib.error.HTTPError as e:
assert e.code == 404
print("ok solid models (SolidModel/CoordinateSystems/chain + /models route)")
finally:
http.stop()
def test_auto_geometry():
import os
import tempfile
from mtc.models import build_models
ini_text = (
"[EMC]\nMACHINE = auto-demo\n"
"[TRAJ]\nCOORDINATES = X Y Z\nLINEAR_UNITS = inch\n"
"[KINS]\nKINEMATICS = trivkins\nJOINTS = 3\n"
"[AXIS_X]\nMIN_LIMIT = -10\nMAX_LIMIT = 10\n"
"[AXIS_Y]\nMIN_LIMIT = -6\nMAX_LIMIT = 6\n"
"[AXIS_Z]\nMIN_LIMIT = -8\nMAX_LIMIT = 0\n"
"[MTCONNECT]\nMODEL_AUTO = 1\nMODEL_PARENT_Z = BASE\nMODEL_INVERT = X Y\n"
)
d = tempfile.mkdtemp()
p = os.path.join(d, "auto.ini")
with open(p, "w") as fh:
fh.write(ini_text)
ini = IniReader(p)
model = kin.build_model(ini)
config = DeviceConfig.from_ini(ini)
mm = build_models(ini, model, config)
assert mm.enabled() and mm.base is not None
assert set(mm.axis) == {"X", "Y", "Z"}, set(mm.axis)
# Geometry is served in the MTConnect canonical unit (millimetre); the inch
# travel limits are scaled up by 25.4 in the generated boxes.
assert mm.units == "MILLIMETER", mm.units
assert all(v.startswith("solid") for v in mm.generated.values())
# X box half-width tracks the 20" span * 25.4 -> ~500 mm scale, not ~20.
xs = [float(tok) for line in mm.generated["axis_x.stl"].splitlines()
if line.strip().startswith("vertex") for tok in [line.split()[1]]]
assert max(xs) > 100, max(xs) # would be < 20 if still in inches
probe = ET.fromstring(probe_xml(model, config, models=mm))
hrefs = {sm.get("href") for sm in probe.findall(".//{%s}SolidModel" % MTC_NS)}
assert {"/models/dev_base.stl", "/models/axis_x.stl", "/models/axis_z.stl"} <= hrefs, hrefs
# the agent serves the generated STL bytes (no file on disk)
state = AgentState(p)
http = HttpAgent(state, host="127.0.0.1", port=0)
http.start()
try:
with urllib.request.urlopen(
"http://127.0.0.1:%d/models/axis_x.stl" % http.port, timeout=5) as r:
assert r.status == 200 and b"endsolid" in r.read()
print("ok auto geometry (generated boxes served + probe SolidModels)")
finally:
http.stop()
def test_ha_discovery():
# ha.py is an optional contrib (Home Assistant is not part of the core agent),
# so skip cleanly if the contrib is not present.
import json
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)),
"contrib"))
try:
import ha
except ImportError:
print("skip HA discovery (contrib not installed)")
return
_, model, config = load(CONFIGS["3axis"])
sensors = ha.build_sensors(model, config)
keys = {s["key"] for s in sensors}
assert {"execution", "spdl_speed", "pos_x", "pos_y", "pos_z"} <= keys, keys
px = [s for s in sensors if s["key"] == "pos_x"][0]
d = ha.discovery_payload(px, config, "st/topic", "av/topic")
assert d["state_topic"] == "st/topic"
assert d["value_template"] == "{{ value_json.pos_x }}"
assert d["unit_of_measurement"] == "mm" # canonical (values are mm)
assert d["device"]["identifiers"] == [config.uuid]
assert d["unique_id"] == "%s_pos_x" % config.uuid
state = json.loads(ha.state_json(
{"execution": "ACTIVE", "pos_x": 1.5, "pos_z": "UNAVAILABLE",
"workoffset": {"G54": {}}}, sensors))
assert state["execution"] == "ACTIVE" and state["pos_x"] == 1.5
assert "pos_z" not in state # UNAVAILABLE skipped
assert "workoffset" not in state # structured value skipped
print("ok HA discovery (sensors + discovery payload + state JSON)")
def test_entry_dump_probe():
# The installed entry point (bin/mtconnect-agent, on PATH via rip-environment).
exe = os.path.join(_EMC2_HOME, "bin", "mtconnect-agent") if _EMC2_HOME \
else "mtconnect-agent"
if not (os.path.isfile(exe) or _EMC2_HOME is None):
print("skip entry --dump-probe (mtconnect-agent not built/installed)")
return
out = subprocess.check_output([exe, "--dump-probe", CONFIGS["5axis"]], text=True)
assert "xyzac-trt-kins" in out and "MTConnectDevices" in out
print("ok entry --dump-probe")
def main():
test_kinematics()
test_probe_registry_consistency()
test_buffer_and_streams()
test_work_offset_table()
test_tool_offset_and_rotation()
test_assets()
test_probe_standard_blocks()
test_spindle_constraints()
test_schema_validation()
test_source_offline()
test_http_endpoints()
test_shdr()
test_hal_items()
test_solid_models()
test_auto_geometry()
test_ha_discovery()
test_entry_dump_probe()
print("\nALL TESTS PASSED")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,71 @@
#!/usr/bin/env python3
# Developer helper: validate the four MTConnect documents this agent produces
# against the official MTConnect 1.7 XSDs (plus the LinuxCNC extension schema for
# the streaming endpoints).
#
# Requires the `xmlschema` package (XSD 1.1; libxml2/xmllint cannot parse the
# 1.7 schema, which uses XSD-1.1 constructs). The official schemas are fetched
# from schemas.mtconnect.org unless a local directory is given via $MTC_XSD_DIR.
#
# pip install xmlschema
# ./validate_schemas.py [INI] # default: example.ini
#
# Exit status is non-zero if any document fails, so it can gate CI.
import io
import os
import sys
BASE = "http://schemas.mtconnect.org/schemas"
def _xsd(name):
d = os.environ.get("MTC_XSD_DIR")
return os.path.join(d, name) if d else "%s/%s" % (BASE, name)
def _bootstrap_mtc():
# mtc installs to $EMC2_HOME/lib/python (on PYTHONPATH under rip-environment);
# add it explicitly so this runs without the full environment.
home = os.environ.get("EMC2_HOME")
if home:
sys.path.insert(0, os.path.join(home, "lib", "python"))
def main(argv):
ini_path = argv[1] if len(argv) > 1 else "example.ini"
try:
import xmlschema
except ImportError:
print("error: needs the 'xmlschema' package (pip install xmlschema)")
return 2
_bootstrap_mtc()
from mtc.agent import AgentState, asset_dir
EXT_XSD = os.path.join(asset_dir(), "mtconnect-linuxcnc-1.xsd")
state = AgentState(ini_path)
state.poll_once()
docs = {
"MTConnectDevices_1.7.xsd": ("/probe", state.probe_document()),
"MTConnectAssets_1.7.xsd": ("/assets", state.assets_document()),
# Streams validate against the extension schema (which imports the base).
EXT_XSD: ("/current", state.current_document()),
}
ok = True
for xsd, (label, doc) in docs.items():
loc = xsd if xsd == EXT_XSD else _xsd(xsd)
schema = xmlschema.XMLSchema11(loc, validation="skip")
errors = list(schema.iter_errors(io.StringIO(doc)))
if errors:
ok = False
print("FAIL %s (%d errors)" % (label, len(errors)))
for e in errors[:8]:
print(" ", e.reason or e.message)
else:
base = "base" if xsd != EXT_XSD else "base+extension"
print("ok %s valid (%s)" % (label, base))
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main(sys.argv))