Update CHANGELOG and documentation for Task Hygiene improvements; enhance error handling in anycubic_lan.py; add Anycubic task display in wand.html.
This commit is contained in:
1 parent
ead7e5718e
commit
b20f8a7779
8 files changed
+467
-67
No files matched your search
+203
-54
@@ -24,13 +24,23 @@ from cryptography.hazmat.primitives import padding
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
|
||||
CRED_TTL = 300
|
||||
INFO_TTL = 10
|
||||
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):
|
||||
def __init__(self, message, code=None):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.code = code
|
||||
|
||||
|
||||
def _info(host):
|
||||
@@ -141,17 +151,103 @@ def normalize_ams(ams_settings):
|
||||
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:
|
||||
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-MQTT). "
|
||||
"Bitte zuerst abbrechen. (code 10101)"
|
||||
"Alter Druckauftrag noch aktiv (Anycubic-Task). "
|
||||
"Wird geräumt und ggf. erneut gestartet. (code 10101)"
|
||||
)
|
||||
message = str(body.get("msg") or default_message)
|
||||
return "%s%s" % (message, (" (code %s)" % code) if code else "")
|
||||
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."):
|
||||
@@ -165,7 +261,7 @@ def _mqtt_print(host, request, timeout=30, timeout_message="Timeout bei MQTT-Dru
|
||||
model_id, device_id,
|
||||
)
|
||||
|
||||
state = {"done": False, "error": None, "report": None}
|
||||
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):
|
||||
@@ -184,9 +280,13 @@ def _mqtt_print(host, request, timeout=30, timeout_message="Timeout bei MQTT-Dru
|
||||
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")
|
||||
text, code = _report_error(body, "Druckstart abgelehnt")
|
||||
state["error"] = text
|
||||
state["code"] = code
|
||||
elif action == "stop" and report in ("failed",):
|
||||
state["error"] = _report_error(body, "Druckabbruch abgelehnt")
|
||||
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
|
||||
@@ -221,59 +321,93 @@ def _mqtt_print(host, request, timeout=30, timeout_message="Timeout bei MQTT-Dru
|
||||
pass
|
||||
|
||||
if state["error"]:
|
||||
raise LanError(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, mit task_settings.auto_leveling 1/0."""
|
||||
"""MQTT-Start wie Rinkhals: Task freimachen, bei 10101 einmal Retry."""
|
||||
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:
|
||||
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
|
||||
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)}
|
||||
if last_exc:
|
||||
raise last_exc
|
||||
raise LanError("Druckstart fehlgeschlagen.")
|
||||
|
||||
|
||||
def print_info(host, timeout=10):
|
||||
"""MQTT info/query, liefert data aus info/report (project.task_id, state …)."""
|
||||
cred = credentials(host)
|
||||
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)
|
||||
@@ -316,6 +450,7 @@ def print_info(host, timeout=10):
|
||||
try:
|
||||
client.connect(host, 9883, keepalive=30)
|
||||
except OSError:
|
||||
_FAIL_CACHE[host] = time.time()
|
||||
return None
|
||||
deadline = time.time() + max(3, int(timeout))
|
||||
try:
|
||||
@@ -326,9 +461,22 @@ def print_info(host, timeout=10):
|
||||
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 = {
|
||||
@@ -344,4 +492,5 @@ def cancel_print(host, timeout=15):
|
||||
timeout=timeout,
|
||||
timeout_message="Timeout beim MQTT-Druckabbruch.",
|
||||
)
|
||||
_INFO_CACHE.pop(host, None)
|
||||
return {"mqtt": "stop", "report": report}
|
||||
+30
-10
@@ -246,21 +246,29 @@ def start_print(host, filename, bed_leveling=False, ams_settings=None):
|
||||
MQTT selbst (auto_leveling aus App-Property; ams_settings aus .acm /
|
||||
Identity-Fallback). Bei bed_leveling oder gesetzter Grotti-ams_settings
|
||||
startet Grotti per MQTTS :9883 und legt die Map explizit in die Payload.
|
||||
|
||||
Vor dem reinen Moonraker-Pfad: best-effort ensure_task_free — Rinkhals
|
||||
wandelt Start oft intern zu MQTT um; hängende gkapi-Tasks (10101) blocken.
|
||||
"""
|
||||
use_mqtt = bool(bed_leveling) or bool(ams_settings)
|
||||
if not use_mqtt:
|
||||
return post_json(host, "/printer/print/start", {"filename": filename})
|
||||
from grotti import anycubic_lan
|
||||
|
||||
if use_mqtt:
|
||||
try:
|
||||
return anycubic_lan.start_print(
|
||||
host,
|
||||
filename,
|
||||
auto_leveling=bool(bed_leveling),
|
||||
ams_settings=ams_settings,
|
||||
)
|
||||
except anycubic_lan.LanError as exc:
|
||||
raise PrinterError(exc.message) from exc
|
||||
|
||||
try:
|
||||
return anycubic_lan.start_print(
|
||||
host,
|
||||
filename,
|
||||
auto_leveling=bool(bed_leveling),
|
||||
ams_settings=ams_settings,
|
||||
)
|
||||
except anycubic_lan.LanError as exc:
|
||||
raise PrinterError(exc.message) from exc
|
||||
anycubic_lan.ensure_task_free(host)
|
||||
except Exception:
|
||||
pass
|
||||
return post_json(host, "/printer/print/start", {"filename": filename})
|
||||
|
||||
|
||||
def command(host, action):
|
||||
@@ -908,9 +916,20 @@ def query(host):
|
||||
"extrude_factor": extrude_factor,
|
||||
"slots": slots(host, status),
|
||||
"exclude": exclude_status(host),
|
||||
"anycubic_task": _anycubic_task_field(host),
|
||||
}
|
||||
|
||||
|
||||
def _anycubic_task_field(host):
|
||||
"""gkapi-Task aus print_info (TTL-Cache in anycubic_lan), best-effort."""
|
||||
try:
|
||||
from grotti import anycubic_lan
|
||||
|
||||
return anycubic_lan.anycubic_task(host)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def snapshot(printers):
|
||||
def one(printer):
|
||||
card = {
|
||||
@@ -935,6 +954,7 @@ def snapshot(printers):
|
||||
"speed_factor": None,
|
||||
"extrude_factor": None,
|
||||
"slots": [],
|
||||
"anycubic_task": None,
|
||||
})
|
||||
card.update(mesh_fields(printer["host"]))
|
||||
return card
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
|
||||
|
||||
__version__ = "2.0.0"
|
||||
__version__ = "2.1.0"
|
||||
Reference in new issue
Block a user