Import LinuxCNC source tree
This commit is contained in:
3
configs/sim/axis/mtconnect/.gitignore
vendored
Normal file
3
configs/sim/axis/mtconnect/.gitignore
vendored
Normal 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/
|
||||
279
configs/sim/axis/mtconnect/README.md
Normal file
279
configs/sim/axis/mtconnect/README.md
Normal 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.
|
||||
94
configs/sim/axis/mtconnect/contrib/ha.py
Normal file
94
configs/sim/axis/mtconnect/contrib/ha.py
Normal 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)
|
||||
160
configs/sim/axis/mtconnect/contrib/mtconnect-ha-bridge
Executable file
160
configs/sim/axis/mtconnect/contrib/mtconnect-ha-bridge
Executable 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())
|
||||
200
configs/sim/axis/mtconnect/example.ini
Normal file
200
configs/sim/axis/mtconnect/example.ini
Normal 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
|
||||
16
configs/sim/axis/mtconnect/mtconnect.hal
Normal file
16
configs/sim/axis/mtconnect/mtconnect.hal
Normal 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
|
||||
639
configs/sim/axis/mtconnect/test_mtc.py
Normal file
639
configs/sim/axis/mtconnect/test_mtc.py
Normal 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())
|
||||
71
configs/sim/axis/mtconnect/validate_schemas.py
Normal file
71
configs/sim/axis/mtconnect/validate_schemas.py
Normal 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))
|
||||
Reference in New Issue
Block a user