Grotti 2.0.0: eigenes Repo, Umbenennung von Farm.

This commit is contained in:
Dominic Gundel committed 2026-10-15 08:45:56 +02:00
commit b2e6443c68
69 files changed
+18719

No files matched your search

+347
View File
@@ -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}