Files
grotti/grotti/anycubic_lan.py
T
Dominic Gundel 0236278929 Grotti 3.0 Phase 1: Migrationen, Pool, Queue- und Abzug-Fixes.
Nummerierte SQL-Migrationen, psycopg-Pool, waitress, hängende Queue-Jobs,
sichere History-Abzüge, Chargen-Locks, Upload-/Host-Validierung, ProxyFix.
2026-10-18 00:47:23 +02:00

497 lines
17 KiB
Python

"""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
INFO_TTL = 45
FAIL_TTL = 45
_CRED_CACHE = {}
_INFO_CACHE = {}
_FAIL_CACHE = {}
# Anycubic MQTT-Report-Codes (Rinkhals kobra.py / mqtt.md)
# Gilt für Kobra 3 und Kobra S1 (gleiche gkapi-Ports :18910 / :9883).
CODE_TASK_ACTIVE = 10101
CODE_FILAMENT = 10107
class LanError(Exception):
def __init__(self, message, code=None):
super().__init__(message)
self.message = message
self.code = code
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 _parse_code(value):
if value is None or value == "":
return None
try:
return int(value)
except (TypeError, ValueError):
return value
def message_for_code(code, default_message="Druckbefehl abgelehnt"):
"""MQTT-Report-Code → deutsche Meldung (für Tests und UI)."""
parsed = _parse_code(code)
if parsed == CODE_FILAMENT:
return "Filament gerissen oder leer. Bitte neues laden. (code 10107)"
if parsed == CODE_TASK_ACTIVE:
return (
"Alter Druckauftrag noch aktiv (Anycubic-Task). "
"Wird geräumt und ggf. erneut gestartet. (code 10101)"
)
message = str(default_message or "Druckbefehl abgelehnt")
if parsed is None:
return message
return "%s (code %s)" % (message, parsed)
def _report_error(body, default_message):
code = _parse_code(body.get("code"))
raw = str(body.get("msg") or "").strip()
if code in (CODE_FILAMENT, CODE_TASK_ACTIVE):
text = message_for_code(code, default_message)
elif raw:
text = "%s%s" % (raw, (" (code %s)" % code) if code is not None else "")
else:
text = message_for_code(code, default_message)
return text, code
def _task_busy(info):
"""True, wenn gkapi einen laufenden/hängenden Task meldet."""
if not isinstance(info, dict):
return False
state = str(info.get("state") or "").strip().lower()
if state and state not in ("free", "idle", "done", ""):
return True
project = info.get("project")
if isinstance(project, dict) and (project.get("task_id") or project.get("filename")):
return True
return False
def task_summary(info):
"""Kompaktes Live-Feld aus print_info-data."""
if not isinstance(info, dict):
return None
project = info.get("project") if isinstance(info.get("project"), dict) else {}
state = str(info.get("state") or "").strip() or None
task_id = project.get("task_id")
if task_id is not None:
task_id = str(task_id)
filename = project.get("filename") or project.get("name") or None
if isinstance(filename, str):
filename = filename.replace("\\", "/").rsplit("/", 1)[-1] or None
if not state and not task_id and not filename:
return None
return {
"state": state,
"task_id": task_id,
"filename": filename,
}
def ensure_task_free(host, attempts=3, wait_s=1.5):
"""Hängenden Anycubic-Task best-effort räumen (Stop + poll).
Kobra 3 und S1. Ohne erreichbares gkapi: None (kein Fehler) — Moonraker-Start
darf nicht an LAN-Ausfall scheitern.
"""
info = print_info(host, timeout=8, use_cache=False)
if info is None or not _task_busy(info):
return info
last_err = None
for _ in range(max(1, int(attempts))):
try:
cancel_print(host, timeout=12)
except LanError as exc:
last_err = exc
time.sleep(max(0.5, float(wait_s)))
info = print_info(host, timeout=8, use_cache=False)
if info is None or not _task_busy(info):
return info
raise LanError(
last_err.message if last_err else (
"Anycubic-Task ließ sich nicht freimachen (state=%s)."
% ((info or {}).get("state") or "?")
),
code=getattr(last_err, "code", CODE_TASK_ACTIVE),
)
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, "code": 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"):
text, code = _report_error(body, "Druckstart abgelehnt")
state["error"] = text
state["code"] = code
elif action == "stop" and report in ("failed",):
text, code = _report_error(body, "Druckabbruch abgelehnt")
state["error"] = text
state["code"] = code
# 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"], code=state.get("code"))
return state["report"]
def start_print(host, filename, auto_leveling=True, ams_settings=None, timeout=30):
"""MQTT-Start wie Rinkhals: Task freimachen, bei 10101 einmal Retry."""
name = (filename or "").replace("\\", "/").split("/")[-1]
if not name:
raise LanError("Dateiname fehlt.")
try:
ensure_task_free(host)
except LanError:
# freimachen fehlgeschlagen — Start trotzdem versuchen (Retry fängt 10101)
pass
ams = normalize_ams(ams_settings)
timeout_message = "Timeout beim MQTT-Druckstart."
last_exc = None
for attempt in range(2):
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,
},
}
if ams:
data["ams_settings"] = ams
request = {
"type": "print",
"action": "start",
"msgid": str(uuid.uuid4()),
"timestamp": int(time.time() * 1000),
"data": data,
}
try:
_mqtt_print(host, request, timeout=timeout, timeout_message=timeout_message)
return {"filename": name, "auto_leveling": bool(auto_leveling)}
except LanError as exc:
last_exc = exc
if exc.message == timeout_message:
for _ in range(3):
project = (print_info(host, use_cache=False) or {}).get("project") or {}
if str(project.get("task_id") or "") == data["taskid"]:
return {"filename": name, "auto_leveling": bool(auto_leveling)}
time.sleep(5)
raise
if exc.code == CODE_TASK_ACTIVE and attempt == 0:
try:
cancel_print(host, timeout=12)
except LanError:
pass
time.sleep(1.5)
continue
raise
if last_exc:
raise last_exc
raise LanError("Druckstart fehlgeschlagen.")
def print_info(host, timeout=10, use_cache=True):
"""MQTT info/query, liefert data aus info/report (project.task_id, state …).
Bei LAN-/MQTT-Fehler: None (kein Raise), kurz negativ cachen — Live-Wand und
Moonraker-Start bleiben für Kobra 3 und S1 nutzbar, wenn gkapi kurz weg ist.
"""
now = time.time()
if use_cache:
cached = _INFO_CACHE.get(host)
if cached and now - cached[0] < INFO_TTL:
return cached[1]
fail_at = _FAIL_CACHE.get(host)
if fail_at and now - fail_at < FAIL_TTL:
return None
try:
cred = credentials(host)
except LanError:
_FAIL_CACHE[host] = time.time()
return None
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:
_FAIL_CACHE[host] = time.time()
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
if state["data"] is None:
_FAIL_CACHE[host] = time.time()
return None
_FAIL_CACHE.pop(host, None)
_INFO_CACHE[host] = (time.time(), state["data"])
return state["data"]
def anycubic_task(host, use_cache=True):
"""Live-Zusammenfassung des gkapi-Tasks (cached), oder null."""
try:
return task_summary(print_info(host, timeout=6, use_cache=use_cache))
except Exception:
return None
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.",
)
_INFO_CACHE.pop(host, None)
return {"mqtt": "stop", "report": report}