Nummerierte SQL-Migrationen, psycopg-Pool, waitress, hängende Queue-Jobs, sichere History-Abzüge, Chargen-Locks, Upload-/Host-Validierung, ProxyFix.
497 lines
17 KiB
Python
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}
|