# Copyright (C) 2026 LinuxCNC contributors # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA # Agent core: holds the machine model, the observation buffer and the live # source, and produces the four MTConnect documents. Transport-agnostic so it # can be driven by the embedded HTTP server (http_agent) or an MQTT publisher. import os import threading from datetime import datetime, timezone from .ini_reader import IniReader from .observations import build_dataitems from .device_model import DeviceConfig, probe_xml from .streams import (ObservationBuffer, current_xml, sample_xml, assets_xml) from .lcnc_source import LcncSource from .hal_items import HalSource from .models import build_models from . import kinematics as kin def now_iso(): return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z" def asset_dir(): """Directory holding runtime data assets (the extension schema). Installed / RIP layout: $EMC2_HOME/share/linuxcnc/mtconnect. Falls back to a 'mtconnect' dir beside this package for an uninstalled source checkout. """ home = os.environ.get("EMC2_HOME") if home: d = os.path.join(home, "share", "linuxcnc", "mtconnect") if os.path.isdir(d): return d return os.path.join(os.path.dirname(os.path.abspath(__file__)), "mtconnect") class OutOfRange(Exception): pass class AgentState: def __init__(self, ini_path, buffer_size=131072): self.ini = IniReader(ini_path) self.model = kin.build_model(self.ini) self.config = DeviceConfig.from_ini(self.ini) self.dataitems = build_dataitems(self.model, self.config) self.buffer = ObservationBuffer(self.dataitems, max_size=buffer_size) self.source = LcncSource(self.model, self.config) self.hal_source = HalSource(self.config.hal_items) self.models = build_models(self.ini, self.model, self.config) self._assets = [] self._last_in_spindle = None self._last_values = {} self._lock = threading.Lock() # -- data collection ----------------------------------------------------- def poll_once(self): """Poll LinuxCNC once and fold new values into the buffer.""" self.source.poll() values = self.source.sample_values() values.update(self.hal_source.sample_values()) # user HAL_ITEM pins assets = self.source.tool_assets() ts = now_iso() with self._lock: self._detect_asset_change(assets, values) self.buffer.ingest(values, ts) self._assets = assets self._last_values = values def latest_values(self): """Flat {dataitem_id: value} snapshot from the last poll (for HA).""" with self._lock: return dict(self._last_values) def _detect_asset_change(self, assets, values): in_spindle = values.get("toolnum") if in_spindle is not None and in_spindle != self._last_in_spindle: if in_spindle and in_spindle > 0: values["assetchg"] = "tool-%d" % in_spindle self._last_in_spindle = in_spindle # -- document builders --------------------------------------------------- def probe_document(self): with self._lock: asset_count = len(self._assets) return probe_xml(self.model, self.config, models=self.models, creation_time=now_iso(), asset_count=asset_count) def extension_schema(self): """Return (bytes, content_type) for the LinuxCNC extension XSD, or None.""" path = os.path.join(asset_dir(), "mtconnect-linuxcnc-1.xsd") if not os.path.isfile(path): return None with open(path, "rb") as fh: return fh.read(), "application/xml" def model_file(self, name): """Return (bytes, content_type) for a served mesh, or None.""" ref = self.models.served.get(name) if ref is None: return None if name in self.models.generated: # auto-generated in memory return self.models.generated[name].encode("utf-8"), ref.content_type if ref.path and os.path.isfile(ref.path): with open(ref.path, "rb") as fh: return fh.read(), ref.content_type return None def current_document(self): with self._lock: return current_xml(self.buffer, self.config, now_iso()) def sample_document(self, from_seq=None, count=100): with self._lock: first, nxt = self.buffer.first_sequence, self.buffer.next_sequence if from_seq is None: from_seq = first if from_seq < first or from_seq > nxt: raise OutOfRange("from=%s out of range [%d,%d]" % (from_seq, first, nxt)) return sample_xml(self.buffer, self.config, now_iso(), from_seq, count) def assets_document(self): with self._lock: assets = list(self._assets) return assets_xml(assets, self.config, now_iso())