Import LinuxCNC source tree
This commit is contained in:
446
lib/python/mtc/device_model.py
Normal file
446
lib/python/mtc/device_model.py
Normal file
@@ -0,0 +1,446 @@
|
||||
# Copyright (C) 2026 LinuxCNC contributors
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 2 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
|
||||
# Build the MTConnectDevices (/probe) document for a LinuxCNC machine.
|
||||
#
|
||||
# The document is "hybrid": a standard MTConnect component tree (Controller,
|
||||
# Path, Axes with Linear/Rotary components and Motion elements) plus a compact
|
||||
# LinuxCNC extension block (<x:Kinematics>) carrying the kins module name, the
|
||||
# coordinates string and the joint<->axis map -- the primary contract for a
|
||||
# FreeCAD auto-configuration plugin.
|
||||
#
|
||||
# The DataItems themselves come from the shared registry (observations.py) so
|
||||
# the probe and the /current and /sample streams cannot drift apart.
|
||||
#
|
||||
# Run standalone to dump a probe document from an INI file:
|
||||
# python3 -m mtc.device_model path/to/machine.ini
|
||||
|
||||
import re
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .ini_reader import IniReader
|
||||
from .observations import build_dataitems, EXT_NS
|
||||
from .hal_items import parse_hal_items, resolve_component
|
||||
from . import kinematics as kin
|
||||
|
||||
MTC_NS = "urn:mtconnect.org:MTConnectDevices:1.7"
|
||||
XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
|
||||
SCHEMA_VERSION = "1.7"
|
||||
|
||||
_LINEAR_UNITS = {
|
||||
"mm": "MILLIMETER", "metric": "MILLIMETER", "millimeter": "MILLIMETER",
|
||||
"inch": "INCH", "imperial": "INCH", "in": "INCH", "cm": "CENTIMETER",
|
||||
}
|
||||
_ANGULAR_UNITS = {
|
||||
"degree": "DEGREE", "degrees": "DEGREE", "deg": "DEGREE",
|
||||
"radian": "RADIAN", "grad": "DEGREE",
|
||||
}
|
||||
# Factor that converts a native length value to millimetres (the MTConnect
|
||||
# canonical length unit). Everything emitted is metric; nativeUnits records the
|
||||
# source so a consumer can recover the original.
|
||||
_LINEAR_SCALE = {"MILLIMETER": 1.0, "INCH": 25.4, "CENTIMETER": 10.0}
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceConfig:
|
||||
name: str = "linuxcnc"
|
||||
uuid: str = "linuxcnc-0001"
|
||||
manufacturer: str = "LinuxCNC"
|
||||
linear_units: str = "MILLIMETER" # canonical emitted unit (always mm)
|
||||
native_linear_units: str = "MILLIMETER" # the machine's own unit (INCH/...)
|
||||
linear_scale: float = 1.0 # native length * scale -> millimetres
|
||||
angular_units: str = "DEGREE"
|
||||
instance_id: str = "1"
|
||||
spindle_speed_min: float = None # [SPINDLE_0] MIN_FORWARD_VELOCITY (RPM)
|
||||
spindle_speed_max: float = None # [SPINDLE_0] MAX_FORWARD_VELOCITY (RPM)
|
||||
hal_items: list = field(default_factory=list) # [MTCONNECT]HAL_ITEM data items
|
||||
|
||||
@property
|
||||
def device_id(self):
|
||||
"""NCName-safe id for the Device component (name may have spaces)."""
|
||||
return "dev_%s" % _nc(self.name)
|
||||
|
||||
@classmethod
|
||||
def from_ini(cls, ini):
|
||||
lin = (ini.find("TRAJ", "LINEAR_UNITS", "mm") or "mm").strip().lower()
|
||||
ang = (ini.find("TRAJ", "ANGULAR_UNITS", "degree") or "degree").strip().lower()
|
||||
native_lin = _LINEAR_UNITS.get(lin, "MILLIMETER")
|
||||
# Spindle 0 usable speed band (RPM); modern configs use [SPINDLE_0],
|
||||
# older ones an unnumbered [SPINDLE]. Only carried through if present.
|
||||
sp = "SPINDLE_0" if ini.has_section("SPINDLE_0") else "SPINDLE"
|
||||
return cls(
|
||||
name=ini.find("MTCONNECT", "DEVICE_NAME",
|
||||
ini.find("EMC", "MACHINE", "linuxcnc")) or "linuxcnc",
|
||||
uuid=ini.find("MTCONNECT", "UUID", "linuxcnc-0001") or "linuxcnc-0001",
|
||||
linear_units="MILLIMETER",
|
||||
native_linear_units=native_lin,
|
||||
linear_scale=_LINEAR_SCALE.get(native_lin, 1.0),
|
||||
angular_units=_ANGULAR_UNITS.get(ang, "DEGREE"),
|
||||
spindle_speed_min=ini.find_float(sp, "MIN_FORWARD_VELOCITY"),
|
||||
spindle_speed_max=ini.find_float(sp, "MAX_FORWARD_VELOCITY"),
|
||||
hal_items=parse_hal_items(ini),
|
||||
)
|
||||
|
||||
|
||||
# Namespaces are declared as literal xmlns attributes on the root (below) and
|
||||
# tags are written with plain / prefixed names. This avoids ElementTree's
|
||||
# global register_namespace() state, which mis-handles several default
|
||||
# namespaces in one process. Serialized output round-trips through any
|
||||
# namespace-aware parser exactly as if {uri}Tag had been used.
|
||||
def _t(tag):
|
||||
return tag
|
||||
|
||||
|
||||
def _x(tag):
|
||||
return "x:" + tag
|
||||
|
||||
|
||||
def _fmt_vec(vec):
|
||||
return " ".join(("%g" % (c if c != 0 else 0.0)) for c in vec) # avoid -0
|
||||
|
||||
|
||||
def _nc(s):
|
||||
"""Coerce an arbitrary string into a valid XML NCName for id attributes.
|
||||
|
||||
Machine names may contain spaces or punctuation (e.g. 'xyzac (switchkins)')
|
||||
which are legal in the MTConnect name attribute but not in an id/NCName.
|
||||
"""
|
||||
s = re.sub(r"[^A-Za-z0-9_.-]", "_", s or "")
|
||||
if not s or not (s[0].isalpha() or s[0] == "_"):
|
||||
s = "_" + s
|
||||
return s
|
||||
|
||||
|
||||
def build_device_element(model, config, models=None):
|
||||
"""Build the <Device> element (standard tree + kinematics extension)."""
|
||||
dev = ET.Element(_t("Device"),
|
||||
{"id": config.device_id, "name": config.name,
|
||||
"uuid": config.uuid})
|
||||
# The compact LinuxCNC kinematics block is an extension element. The only
|
||||
# schema-valid host for foreign-namespace elements is a Description (its
|
||||
# content model is a lax xs:any); putting it here keeps the whole document
|
||||
# valid against the standard MTConnectDevices schema.
|
||||
desc = ET.SubElement(dev, _t("Description"), {"manufacturer": config.manufacturer})
|
||||
_build_kinematics_extension(desc, model, config)
|
||||
|
||||
# Device-level Configuration: the WORLD/MACHINE coordinate systems are always
|
||||
# emitted so every Motion/SolidModel coordinateSystemIdRef='machine' resolves;
|
||||
# the static frame SolidModel is added only when geometry is configured.
|
||||
cfg = ET.SubElement(dev, _t("Configuration"))
|
||||
_build_coordinate_systems(cfg)
|
||||
if models and models.enabled() and models.base:
|
||||
_solid_model(cfg, "dev_base_model", models.base, models)
|
||||
|
||||
# containers maps a component id to its (created, empty) <DataItems> element;
|
||||
# the registry loop below fills them so probe and streams stay in lockstep.
|
||||
containers = {}
|
||||
containers[config.device_id] = ET.SubElement(dev, _t("DataItems"))
|
||||
|
||||
components = ET.SubElement(dev, _t("Components"))
|
||||
_build_controller(components, containers)
|
||||
_build_axes(components, model, config, containers, models)
|
||||
_build_systems(components, containers)
|
||||
_build_auxiliaries(components, containers, model, config)
|
||||
|
||||
for di in build_dataitems(model, config):
|
||||
parent = containers.get(di.comp_id)
|
||||
if parent is not None:
|
||||
_emit_dataitem(parent, di)
|
||||
|
||||
return dev
|
||||
|
||||
|
||||
def _emit_dataitem(parent, di):
|
||||
type_ = ("x:" + di.type) if di.ext else di.type
|
||||
attrs = {"category": di.category, "type": type_, "id": di.id}
|
||||
if di.subType:
|
||||
attrs["subType"] = di.subType
|
||||
if di.units:
|
||||
attrs["units"] = di.units
|
||||
if di.native_units:
|
||||
attrs["nativeUnits"] = di.native_units
|
||||
if di.representation:
|
||||
attrs["representation"] = di.representation
|
||||
item = ET.SubElement(parent, _t("DataItem"), attrs)
|
||||
if di.constraints:
|
||||
constraints = ET.SubElement(item, _t("Constraints"))
|
||||
if di.constraints.get("minimum") is not None:
|
||||
ET.SubElement(constraints, _t("Minimum")).text = "%g" % di.constraints["minimum"]
|
||||
if di.constraints.get("maximum") is not None:
|
||||
ET.SubElement(constraints, _t("Maximum")).text = "%g" % di.constraints["maximum"]
|
||||
|
||||
|
||||
def _build_controller(parent, containers):
|
||||
ctrl = ET.SubElement(parent, _t("Controller"), {"id": "ctrl", "name": "controller"})
|
||||
containers["ctrl"] = ET.SubElement(ctrl, _t("DataItems"))
|
||||
paths = ET.SubElement(ctrl, _t("Components"))
|
||||
path = ET.SubElement(paths, _t("Path"), {"id": "path", "name": "path"})
|
||||
containers["path"] = ET.SubElement(path, _t("DataItems"))
|
||||
|
||||
|
||||
def _build_axes(parent, model, config, containers, models=None):
|
||||
axes = ET.SubElement(parent, _t("Axes"), {"id": "axes", "name": "axes"})
|
||||
comps = ET.SubElement(axes, _t("Components"))
|
||||
for axis in model.axes:
|
||||
_build_motion_axis(comps, axis, containers, models, config, model.is_serial)
|
||||
_build_spindle(comps, containers, models, config)
|
||||
|
||||
|
||||
def _build_motion_axis(parent, axis, containers, models=None, config=None,
|
||||
serial=True):
|
||||
is_linear = axis.kind == "LINEAR"
|
||||
aid = axis.letter.lower()
|
||||
comp = ET.SubElement(parent, _t("Linear" if is_linear else "Rotary"),
|
||||
{"id": "axis_%s" % aid, "name": axis.letter})
|
||||
cfg = ET.SubElement(comp, _t("Configuration"))
|
||||
motion = ET.SubElement(cfg, _t("Motion"), {
|
||||
"id": "motion_%s" % aid,
|
||||
"type": "PRISMATIC" if is_linear else "REVOLUTE",
|
||||
"actuation": "DIRECT",
|
||||
"coordinateSystemIdRef": "machine",
|
||||
})
|
||||
# Chain this link to its parent link's motion so the twin nests transforms.
|
||||
if models and models.enabled():
|
||||
parent_id = models.parent_of("axis_%s" % aid)
|
||||
if parent_id and parent_id.startswith("axis_"):
|
||||
motion.set("parentIdRef", "motion_%s" % parent_id.split("_", 1)[1])
|
||||
# The direction vector is only meaningful when the axis is an orthogonal
|
||||
# Cartesian DOF; for non-serial kinematics it is not INI-derivable, so the
|
||||
# Motion element carries type/actuation but no (misleading) direction.
|
||||
if serial:
|
||||
vec = axis.vector
|
||||
if models and axis.letter in models.invert:
|
||||
vec = tuple(-c for c in vec) # work-carrying axis moves opposite
|
||||
ET.SubElement(motion, _t("Axis")).text = _fmt_vec(vec)
|
||||
if models and axis.letter in models.axis:
|
||||
_solid_model(cfg, "model_%s" % aid, models.axis[axis.letter], models)
|
||||
# Mirror the travel limits as a standard Specification so non-LinuxCNC
|
||||
# consumers get the work envelope without reading the x:Kinematics block.
|
||||
_axis_specification(cfg, axis, config)
|
||||
containers["axis_%s" % aid] = ET.SubElement(comp, _t("DataItems"))
|
||||
|
||||
|
||||
def _axis_specification(cfg, axis, config):
|
||||
if axis.min_limit is None and axis.max_limit is None:
|
||||
return
|
||||
is_linear = axis.kind == "LINEAR"
|
||||
scale = (config.linear_scale if (config and is_linear) else 1.0)
|
||||
units = ((config.linear_units if config else "MILLIMETER") if is_linear
|
||||
else (config.angular_units if config else "DEGREE"))
|
||||
dtype = "POSITION" if is_linear else "ANGLE"
|
||||
specs = ET.SubElement(cfg, _t("Specifications"))
|
||||
# NB: SpecificationType has no nativeUnits attribute; values are canonical.
|
||||
attrs = {"id": "axis_%s_travel" % axis.letter.lower(), "type": dtype,
|
||||
"units": units, "name": "%s travel" % axis.letter}
|
||||
spec = ET.SubElement(specs, _t("Specification"), attrs)
|
||||
if axis.max_limit is not None:
|
||||
ET.SubElement(spec, _t("Maximum")).text = "%g" % (axis.max_limit * scale)
|
||||
if axis.min_limit is not None:
|
||||
ET.SubElement(spec, _t("Minimum")).text = "%g" % (axis.min_limit * scale)
|
||||
|
||||
|
||||
def _build_spindle(parent, containers, models=None, config=None):
|
||||
comp = ET.SubElement(parent, _t("Rotary"), {"id": "spindle", "name": "S"})
|
||||
has_spec = config is not None and (config.spindle_speed_min is not None
|
||||
or config.spindle_speed_max is not None)
|
||||
if (models and models.spindle) or has_spec:
|
||||
cfg = ET.SubElement(comp, _t("Configuration"))
|
||||
if has_spec:
|
||||
_spindle_specifications(cfg, config)
|
||||
if models and models.spindle:
|
||||
_solid_model(cfg, "model_spindle", models.spindle, models)
|
||||
containers["spindle"] = ET.SubElement(comp, _t("DataItems"))
|
||||
|
||||
|
||||
def _spindle_specifications(cfg, config):
|
||||
"""Spindle usable speed band as a standard MTConnect Specification."""
|
||||
specs = ET.SubElement(cfg, _t("Specifications"))
|
||||
spec = ET.SubElement(specs, _t("Specification"), {
|
||||
"id": "spdl_speed_spec", "type": "ROTARY_VELOCITY",
|
||||
"units": "REVOLUTION/MINUTE", "name": "spindle speed",
|
||||
})
|
||||
if config.spindle_speed_max is not None:
|
||||
ET.SubElement(spec, _t("Maximum")).text = "%g" % config.spindle_speed_max
|
||||
if config.spindle_speed_min is not None:
|
||||
ET.SubElement(spec, _t("Minimum")).text = "%g" % config.spindle_speed_min
|
||||
|
||||
|
||||
def _build_systems(parent, containers):
|
||||
"""Systems container with a Coolant component (flood/mist events)."""
|
||||
systems = ET.SubElement(parent, _t("Systems"), {"id": "systems", "name": "systems"})
|
||||
comps = ET.SubElement(systems, _t("Components"))
|
||||
coolant = ET.SubElement(comps, _t("Coolant"), {"id": "coolant", "name": "coolant"})
|
||||
containers["coolant"] = ET.SubElement(coolant, _t("DataItems"))
|
||||
|
||||
|
||||
def _build_auxiliaries(parent, containers, model, config):
|
||||
"""Host generic user HAL items ([MTCONNECT]HAL_ITEM) that target the default
|
||||
'sensors' component, in an Auxiliaries > Sensor container. Items that target
|
||||
an existing component (spindle, controller, path, an axis) land there instead
|
||||
and need nothing here.
|
||||
"""
|
||||
wants_sensor = any(resolve_component(it.component, model)[0] == "sensors"
|
||||
for it in getattr(config, "hal_items", None) or [])
|
||||
if not wants_sensor:
|
||||
return
|
||||
aux = ET.SubElement(parent, _t("Auxiliaries"), {"id": "aux", "name": "aux"})
|
||||
comps = ET.SubElement(aux, _t("Components"))
|
||||
sensor = ET.SubElement(comps, _t("Sensor"), {"id": "sensors", "name": "sensors"})
|
||||
containers["sensors"] = ET.SubElement(sensor, _t("DataItems"))
|
||||
|
||||
|
||||
def _build_coordinate_systems(cfg):
|
||||
cs = ET.SubElement(cfg, _t("CoordinateSystems"))
|
||||
ET.SubElement(cs, _t("CoordinateSystem"),
|
||||
{"id": "world", "type": "WORLD", "name": "world"})
|
||||
machine = ET.SubElement(cs, _t("CoordinateSystem"),
|
||||
{"id": "machine", "type": "MACHINE", "name": "machine",
|
||||
"parentIdRef": "world"})
|
||||
ET.SubElement(machine, _t("Origin")).text = "0 0 0"
|
||||
|
||||
|
||||
def _solid_model(cfg, sid, ref, models):
|
||||
# NB: the MTConnect SolidModel element has no units/nativeUnits attributes;
|
||||
# served geometry is expected in the canonical millimetre coordinate space.
|
||||
ET.SubElement(cfg, _t("SolidModel"), {
|
||||
"id": sid,
|
||||
"href": "/models/%s" % ref.name,
|
||||
"mediaType": ref.media,
|
||||
"coordinateSystemIdRef": "machine",
|
||||
})
|
||||
|
||||
|
||||
def _build_kinematics_extension(parent, model, config):
|
||||
"""Compact LinuxCNC-specific kinematic block for auto-configuration.
|
||||
|
||||
Linear limits are converted to millimetres (matching every other emitted
|
||||
length); linearUnits / nativeLinearUnits record the canonical and source
|
||||
units so a consumer can recover the machine's native values.
|
||||
"""
|
||||
lin = getattr(config, "linear_scale", 1.0)
|
||||
k = ET.SubElement(parent, _x("Kinematics"), {
|
||||
"module": model.kins_module,
|
||||
"coordinates": model.coordinates,
|
||||
"joints": str(model.joints_count),
|
||||
"linearUnits": config.linear_units,
|
||||
"nativeLinearUnits": config.native_linear_units,
|
||||
})
|
||||
if model.kins_params:
|
||||
k.set("params", model.kins_params)
|
||||
if model.kinematics_type:
|
||||
k.set("type", model.kinematics_type)
|
||||
|
||||
jmap = ET.SubElement(k, _x("JointMap"))
|
||||
for joint in model.joints:
|
||||
js = lin if joint.kind == "LINEAR" else 1.0
|
||||
attrs = {"number": str(joint.number), "kind": joint.kind}
|
||||
if joint.axis and model.is_serial:
|
||||
attrs["axis"] = joint.axis
|
||||
_set_num(attrs, "min", joint.min_limit, js)
|
||||
_set_num(attrs, "max", joint.max_limit, js)
|
||||
_set_num(attrs, "home", joint.home, js)
|
||||
_set_num(attrs, "homeOffset", joint.home_offset, js)
|
||||
ET.SubElement(jmap, _x("Joint"), attrs)
|
||||
|
||||
for axis in model.axes:
|
||||
axs = lin if axis.kind == "LINEAR" else 1.0
|
||||
attrs = {"name": axis.letter, "kind": axis.kind}
|
||||
if model.is_serial:
|
||||
attrs["vector"] = _fmt_vec(axis.vector)
|
||||
_set_num(attrs, "min", axis.min_limit, axs)
|
||||
_set_num(attrs, "max", axis.max_limit, axs)
|
||||
ET.SubElement(k, _x("Axis"), attrs)
|
||||
|
||||
|
||||
def _set_num(attrs, key, value, scale=1.0):
|
||||
if value is not None:
|
||||
attrs[key] = "%g" % (value * scale)
|
||||
|
||||
|
||||
def build_probe_tree(model, config, creation_time="1970-01-01T00:00:00Z",
|
||||
asset_count=0, models=None):
|
||||
"""Build the full <MTConnectDevices> ElementTree root."""
|
||||
root = ET.Element("MTConnectDevices", {
|
||||
"xmlns": MTC_NS,
|
||||
"xmlns:xsi": XSI_NS,
|
||||
"xmlns:x": EXT_NS,
|
||||
"xsi:schemaLocation":
|
||||
"urn:mtconnect.org:MTConnectDevices:%s "
|
||||
"http://schemas.mtconnect.org/schemas/MTConnectDevices_%s.xsd"
|
||||
% (SCHEMA_VERSION, SCHEMA_VERSION),
|
||||
})
|
||||
ET.SubElement(root, _t("Header"), {
|
||||
"creationTime": creation_time,
|
||||
"sender": "linuxcnc-mtconnect",
|
||||
"instanceId": config.instance_id,
|
||||
"version": SCHEMA_VERSION,
|
||||
"deviceModelChangeTime": creation_time,
|
||||
"assetCount": str(asset_count),
|
||||
"assetBufferSize": "1024",
|
||||
"bufferSize": "131072",
|
||||
})
|
||||
devices = ET.SubElement(root, _t("Devices"))
|
||||
# The schema requires an Agent device alongside the machine Device(s); it
|
||||
# is the self-description of this agent process.
|
||||
_build_agent_element(devices, config)
|
||||
devices.append(build_device_element(model, config, models))
|
||||
return root
|
||||
|
||||
|
||||
def _build_agent_element(devices, config):
|
||||
agent = ET.SubElement(devices, _t("Agent"), {
|
||||
"id": "agent_%s" % _nc(config.name), "name": "%s_agent" % config.name,
|
||||
"uuid": "%s_agent" % config.uuid, "mtconnectVersion": SCHEMA_VERSION,
|
||||
})
|
||||
ET.SubElement(agent, _t("Description"),
|
||||
{"manufacturer": "LinuxCNC"}).text = "LinuxCNC MTConnect agent"
|
||||
items = ET.SubElement(agent, _t("DataItems"))
|
||||
ET.SubElement(items, _t("DataItem"),
|
||||
{"category": "EVENT", "type": "AVAILABILITY", "id": "agent_avail"})
|
||||
for t in ("DEVICE_ADDED", "DEVICE_REMOVED", "DEVICE_CHANGED"):
|
||||
ET.SubElement(items, _t("DataItem"),
|
||||
{"category": "EVENT", "type": t, "id": "agent_%s" % t.lower()})
|
||||
|
||||
|
||||
def probe_xml(model, config, models=None, **kwargs):
|
||||
"""Return the pretty-printed MTConnectDevices document as a string."""
|
||||
root = build_probe_tree(model, config, models=models, **kwargs)
|
||||
ET.indent(root, space=" ")
|
||||
body = ET.tostring(root, encoding="unicode")
|
||||
return '<?xml version="1.0" encoding="UTF-8"?>\n' + body + "\n"
|
||||
|
||||
|
||||
def probe_from_ini(ini_path, **kwargs):
|
||||
"""Convenience: build a probe document straight from an INI file path."""
|
||||
from .models import build_models
|
||||
ini = IniReader(ini_path)
|
||||
model = kin.build_model(ini)
|
||||
config = DeviceConfig.from_ini(ini)
|
||||
return probe_xml(model, config, models=build_models(ini, model, config), **kwargs)
|
||||
|
||||
|
||||
def _main(argv):
|
||||
if len(argv) != 2:
|
||||
sys.stderr.write("usage: python3 -m mtc.device_model MACHINE.ini\n")
|
||||
return 2
|
||||
sys.stdout.write(probe_from_ini(argv[1]))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(_main(sys.argv))
|
||||
Reference in New Issue
Block a user