Import LinuxCNC source tree
This commit is contained in:
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())
|
||||
Reference in New Issue
Block a user