Grotti 2.0.0: eigenes Repo, Umbenennung von Farm.
This commit is contained in:
commit
b2e6443c68
69 files changed
+18719
No files matched your search
@@ -0,0 +1,347 @@
|
||||
"""Anycubic LAN: Credentials über :18910, Start/Stop über MQTTS :9883.
|
||||
|
||||
Entspricht Rinkhals kobra.py mqtt_print_file, aber mit pro-Job task_settings
|
||||
(statt globaler App-Property mqtt_print_auto_leveling). Stop: action stop,
|
||||
taskid -1 (Rinkhals MQTT-Docs) — nötig, weil Moonraker-cancel den Anycubic-Task
|
||||
nicht räumt (sonst 10101).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import random
|
||||
import ssl
|
||||
import string
|
||||
import time
|
||||
import uuid
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from base64 import b64decode
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
from cryptography.hazmat.primitives import padding
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
|
||||
CRED_TTL = 300
|
||||
_CRED_CACHE = {}
|
||||
|
||||
|
||||
class LanError(Exception):
|
||||
def __init__(self, message):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
|
||||
|
||||
def _info(host):
|
||||
try:
|
||||
with urllib.request.urlopen("http://%s:18910/info" % host, timeout=5) as response:
|
||||
return json.load(response)
|
||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
|
||||
raise LanError("LAN-Info vom Drucker nicht erreichbar (:18910).") from exc
|
||||
|
||||
|
||||
def _ctrl(host, token, did="grotti"):
|
||||
ts = str(int(time.time() * 1000))
|
||||
nonce = "".join(random.choice(string.ascii_letters + string.digits) for _ in range(6))
|
||||
sign = hashlib.md5(
|
||||
(hashlib.md5(token[:16].encode()).hexdigest() + ts + nonce).encode()
|
||||
).hexdigest()
|
||||
url = "http://%s:18910/ctrl?ts=%s&nonce=%s&sign=%s&did=%s" % (
|
||||
host, ts, nonce, sign, did,
|
||||
)
|
||||
try:
|
||||
request = urllib.request.Request(url, data=b"", method="POST")
|
||||
with urllib.request.urlopen(request, timeout=8) as response:
|
||||
payload = json.load(response)
|
||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
|
||||
raise LanError("LAN-Credentials vom Drucker nicht erreichbar (:18910/ctrl).") from exc
|
||||
if payload.get("code") != 200 or not isinstance(payload.get("data"), dict):
|
||||
raise LanError("LAN-Credentials abgelehnt.")
|
||||
return payload["data"]
|
||||
|
||||
|
||||
def _decrypt(info_token, ctrl_data):
|
||||
key = info_token[16:32].encode("ascii")
|
||||
iv_raw = ctrl_data.get("token") or ""
|
||||
iv = iv_raw.encode("ascii")[:16] if isinstance(iv_raw, str) else b""
|
||||
if len(iv) != 16:
|
||||
raise LanError("LAN-Credentials ungültig (IV).")
|
||||
try:
|
||||
blob = b64decode(ctrl_data["info"])
|
||||
cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
|
||||
decryptor = cipher.decryptor()
|
||||
raw = decryptor.update(blob) + decryptor.finalize()
|
||||
unpadder = padding.PKCS7(128).unpadder()
|
||||
plain = unpadder.update(raw) + unpadder.finalize()
|
||||
data = json.loads(plain.decode("utf-8"))
|
||||
except Exception as exc:
|
||||
raise LanError("LAN-Credentials lassen sich nicht lesen.") from exc
|
||||
if not data.get("deviceId") or not data.get("username") or not data.get("password"):
|
||||
raise LanError("LAN-Credentials unvollständig.")
|
||||
return data
|
||||
|
||||
|
||||
def credentials(host):
|
||||
now = time.time()
|
||||
cached = _CRED_CACHE.get(host)
|
||||
if cached and now - cached[0] < CRED_TTL:
|
||||
return cached[1]
|
||||
info = _info(host)
|
||||
token = info.get("token") or ""
|
||||
if len(token) < 32:
|
||||
raise LanError("LAN-Info ohne Token.")
|
||||
data = _decrypt(token, _ctrl(host, token))
|
||||
data["modelId"] = str(info.get("modelId") or data.get("modeId") or data.get("modelId") or "")
|
||||
if not data["modelId"]:
|
||||
raise LanError("LAN-Info ohne modelId.")
|
||||
_CRED_CACHE[host] = (now, data)
|
||||
return data
|
||||
|
||||
|
||||
def _rgba(value):
|
||||
if isinstance(value, (list, tuple)) and len(value) >= 3:
|
||||
channels = []
|
||||
for channel in value[:3]:
|
||||
if isinstance(channel, bool) or not isinstance(channel, (int, float)):
|
||||
return [0, 0, 0, 255]
|
||||
channels.append(max(0, min(255, int(round(float(channel))))))
|
||||
alpha = 255
|
||||
if len(value) >= 4 and isinstance(value[3], (int, float)) and not isinstance(value[3], bool):
|
||||
alpha = max(0, min(255, int(round(float(value[3])))))
|
||||
return channels + [alpha]
|
||||
return [0, 0, 0, 255]
|
||||
|
||||
|
||||
def normalize_ams(ams_settings):
|
||||
if not isinstance(ams_settings, dict):
|
||||
return None
|
||||
mapping = ams_settings.get("ams_box_mapping")
|
||||
if not isinstance(mapping, list) or not mapping:
|
||||
return None
|
||||
entries = []
|
||||
for item in mapping:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
try:
|
||||
paint = int(item.get("paint_index"))
|
||||
ams = int(item.get("ams_index"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
material = (item.get("material_type") or "PLA").strip() or "PLA"
|
||||
entries.append({
|
||||
"paint_index": paint,
|
||||
"ams_index": ams,
|
||||
"paint_color": _rgba(item.get("paint_color")),
|
||||
"ams_color": _rgba(item.get("ams_color") or item.get("paint_color")),
|
||||
"material_type": material,
|
||||
})
|
||||
if not entries:
|
||||
return None
|
||||
return {"use_ams": True, "ams_box_mapping": entries}
|
||||
|
||||
|
||||
def _report_error(body, default_message):
|
||||
code = body.get("code")
|
||||
if code == 10107:
|
||||
return "Filament gerissen. Bitte neues laden. (code 10107)"
|
||||
if code == 10101:
|
||||
return (
|
||||
"Alter Druckauftrag noch aktiv (Anycubic-MQTT). "
|
||||
"Bitte zuerst abbrechen. (code 10101)"
|
||||
)
|
||||
message = str(body.get("msg") or default_message)
|
||||
return "%s%s" % (message, (" (code %s)" % code) if code else "")
|
||||
|
||||
|
||||
def _mqtt_print(host, request, timeout=30, timeout_message="Timeout bei MQTT-Druckbefehl."):
|
||||
"""Publish auf slicer/…/print, warte auf print/report."""
|
||||
cred = credentials(host)
|
||||
model_id = cred["modelId"]
|
||||
device_id = cred["deviceId"]
|
||||
payload = json.dumps(request)
|
||||
topic_print = "anycubic/anycubicCloud/v1/slicer/printer/%s/%s/print" % (model_id, device_id)
|
||||
topic_report = "anycubic/anycubicCloud/v1/printer/public/%s/%s/print/report" % (
|
||||
model_id, device_id,
|
||||
)
|
||||
|
||||
state = {"done": False, "error": None, "report": None}
|
||||
|
||||
def on_connect(client, userdata, flags, reason_code, properties=None):
|
||||
if getattr(reason_code, "is_failure", False):
|
||||
state["error"] = "MQTT-Verbindung abgelehnt (%s)." % reason_code
|
||||
state["done"] = True
|
||||
return
|
||||
client.subscribe(topic_report)
|
||||
client.publish(topic_print, payload=payload, qos=1)
|
||||
|
||||
def on_message(client, userdata, msg):
|
||||
try:
|
||||
body = json.loads(msg.payload.decode("utf-8", "replace"))
|
||||
except (json.JSONDecodeError, UnicodeError):
|
||||
return
|
||||
state["report"] = body
|
||||
report = str(body.get("state") or "")
|
||||
action = str(request.get("action") or "")
|
||||
if action == "start" and report in ("failed", "stoped", "stopped"):
|
||||
state["error"] = _report_error(body, "Druckstart abgelehnt")
|
||||
elif action == "stop" and report in ("failed",):
|
||||
state["error"] = _report_error(body, "Druckabbruch abgelehnt")
|
||||
# start: heating / leveling / printing / preheating = angenommen
|
||||
# stop: stoped / stopped / done = angenommen
|
||||
state["done"] = True
|
||||
|
||||
client = mqtt.Client(
|
||||
mqtt.CallbackAPIVersion.VERSION2,
|
||||
protocol=mqtt.MQTTv311,
|
||||
client_id="grotti-%s" % uuid.uuid4().hex[:10],
|
||||
)
|
||||
client.username_pw_set(cred["username"], cred["password"])
|
||||
client.tls_set(cert_reqs=ssl.CERT_NONE)
|
||||
client.tls_insecure_set(True)
|
||||
client.on_connect = on_connect
|
||||
client.on_message = on_message
|
||||
|
||||
try:
|
||||
client.connect(host, 9883, keepalive=30)
|
||||
except OSError as exc:
|
||||
raise LanError("MQTT am Drucker nicht erreichbar (:9883).") from exc
|
||||
|
||||
deadline = time.time() + max(5, int(timeout))
|
||||
try:
|
||||
while not state["done"]:
|
||||
if time.time() > deadline:
|
||||
state["error"] = timeout_message
|
||||
break
|
||||
client.loop(timeout=0.25)
|
||||
finally:
|
||||
try:
|
||||
client.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if state["error"]:
|
||||
raise LanError(state["error"])
|
||||
return state["report"]
|
||||
|
||||
|
||||
def start_print(host, filename, auto_leveling=True, ams_settings=None, timeout=30):
|
||||
"""MQTT-Start wie Rinkhals, mit task_settings.auto_leveling 1/0."""
|
||||
name = (filename or "").replace("\\", "/").split("/")[-1]
|
||||
if not name:
|
||||
raise LanError("Dateiname fehlt.")
|
||||
|
||||
data = {
|
||||
"filename": name,
|
||||
"filepath": "/",
|
||||
"taskid": str(random.randint(0, 1000000)),
|
||||
"task_mode": 1,
|
||||
"filetype": 1,
|
||||
"task_settings": {
|
||||
"auto_leveling": 1 if auto_leveling else 0,
|
||||
"vibration_compensation": 0,
|
||||
"flow_calibration": 0,
|
||||
},
|
||||
}
|
||||
ams = normalize_ams(ams_settings)
|
||||
if ams:
|
||||
data["ams_settings"] = ams
|
||||
|
||||
request = {
|
||||
"type": "print",
|
||||
"action": "start",
|
||||
"msgid": str(uuid.uuid4()),
|
||||
"timestamp": int(time.time() * 1000),
|
||||
"data": data,
|
||||
}
|
||||
timeout_message = "Timeout beim MQTT-Druckstart."
|
||||
try:
|
||||
_mqtt_print(host, request, timeout=timeout, timeout_message=timeout_message)
|
||||
except LanError as exc:
|
||||
# Nach Bettnetz-Prüfung kam das Start-Report schon > 30 s zu spät, der Druck lief trotzdem.
|
||||
if exc.message != timeout_message:
|
||||
raise
|
||||
for attempt in range(3):
|
||||
project = (print_info(host) or {}).get("project") or {}
|
||||
if str(project.get("task_id") or "") == data["taskid"]:
|
||||
break
|
||||
time.sleep(5)
|
||||
else:
|
||||
raise
|
||||
return {"filename": name, "auto_leveling": bool(auto_leveling)}
|
||||
|
||||
|
||||
def print_info(host, timeout=10):
|
||||
"""MQTT info/query, liefert data aus info/report (project.task_id, state …)."""
|
||||
cred = credentials(host)
|
||||
model_id = cred["modelId"]
|
||||
device_id = cred["deviceId"]
|
||||
topic_query = "anycubic/anycubicCloud/v1/slicer/printer/%s/%s/info" % (model_id, device_id)
|
||||
topic_report = "anycubic/anycubicCloud/v1/printer/public/%s/%s/info/report" % (
|
||||
model_id, device_id,
|
||||
)
|
||||
payload = json.dumps({
|
||||
"type": "info",
|
||||
"action": "query",
|
||||
"msgid": str(uuid.uuid4()),
|
||||
"timestamp": int(time.time() * 1000),
|
||||
"data": None,
|
||||
})
|
||||
state = {"data": None}
|
||||
|
||||
def on_connect(client, userdata, flags, reason_code, properties=None):
|
||||
if getattr(reason_code, "is_failure", False):
|
||||
return
|
||||
client.subscribe(topic_report)
|
||||
client.publish(topic_query, payload=payload, qos=1)
|
||||
|
||||
def on_message(client, userdata, msg):
|
||||
try:
|
||||
body = json.loads(msg.payload.decode("utf-8", "replace"))
|
||||
except (json.JSONDecodeError, UnicodeError):
|
||||
return
|
||||
if isinstance(body.get("data"), dict):
|
||||
state["data"] = body["data"]
|
||||
|
||||
client = mqtt.Client(
|
||||
mqtt.CallbackAPIVersion.VERSION2,
|
||||
protocol=mqtt.MQTTv311,
|
||||
client_id="grotti-%s" % uuid.uuid4().hex[:10],
|
||||
)
|
||||
client.username_pw_set(cred["username"], cred["password"])
|
||||
client.tls_set(cert_reqs=ssl.CERT_NONE)
|
||||
client.tls_insecure_set(True)
|
||||
client.on_connect = on_connect
|
||||
client.on_message = on_message
|
||||
try:
|
||||
client.connect(host, 9883, keepalive=30)
|
||||
except OSError:
|
||||
return None
|
||||
deadline = time.time() + max(3, int(timeout))
|
||||
try:
|
||||
while state["data"] is None and time.time() < deadline:
|
||||
client.loop(timeout=0.25)
|
||||
finally:
|
||||
try:
|
||||
client.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
return state["data"]
|
||||
|
||||
|
||||
def cancel_print(host, timeout=15):
|
||||
"""MQTT-Stop wie Rinkhals Docs: action stop, taskid -1 (Anycubic-Task)."""
|
||||
request = {
|
||||
"type": "print",
|
||||
"action": "stop",
|
||||
"msgid": str(uuid.uuid4()),
|
||||
"timestamp": int(time.time() * 1000),
|
||||
"data": {"taskid": "-1"},
|
||||
}
|
||||
report = _mqtt_print(
|
||||
host,
|
||||
request,
|
||||
timeout=timeout,
|
||||
timeout_message="Timeout beim MQTT-Druckabbruch.",
|
||||
)
|
||||
return {"mqtt": "stop", "report": report}
|
||||
Reference in new issue
Block a user