diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e01798..1196f7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,10 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo ### Neu +- Task-Hygiene wie Rinkhals (Kobra 3 und S1): `ensure_task_free` vor MQTT- und Moonraker-Start; bei Report **10101** einmal cancel + Retry; klarere Codes (**10101**, **10107**); LAN-Ausfall weich (negativer Cache). +- Live-Snapshot `anycubic_task` (gkapi-State/task_id) auf der Wand. - Cursor-Regel `.cursor/rules/grotti.mdc` (Changelog, Doku, Version, Sicherheit). -- Doku [Anycubic-Stack](docs/Anycubic-Stack.md): Schichten gklib/gkapi/Rinkhals, MQTT Start/Stop/10101, ACE/`.acm`, FIRMWARE_RESTART — aus Klipper-go und Rinkhals-Quellcode. +- Doku [Anycubic-Stack](docs/Anycubic-Stack.md): Schichten gklib/gkapi/Rinkhals, MQTT Start/Stop/10101, ACE/`.acm`, FIRMWARE_RESTART — aus Klipper-go und Rinkhals-Quellcode; Abschnitt Task-Hygiene. - Doku [Glossar](docs/Glossar.md): Moonraker, Mainsail, gklib, Rinkhals, OctoPrint und Nachbarn. ## [2.0.0] – 2026-10-15 diff --git a/docs/Anycubic-Stack.md b/docs/Anycubic-Stack.md index 6d1bfca..2d7c584 100644 --- a/docs/Anycubic-Stack.md +++ b/docs/Anycubic-Stack.md @@ -65,6 +65,21 @@ Aus Rinkhals-Doku `firmware/mqtt.md` und `kobra.py`: Moonraker `/printer/print/cancel` trifft nur gklib (`print_stats` → `cancelled`). Der Anycubic-Task in gkapi bleibt → nächster MQTT-Start mit **code 10101** („Print task already exists“ / chinesische Meldung). Rinkhals `mqtt_print_file` kennt 10101 und **retryt einmal**; Grotti sendet bei Abbruch Moonraker-cancel **und** MQTT-stop. +### Task-Hygiene (wie Rinkhals) + +Vorbild: `kobra.py` `mqtt_print_file` + `firmware/mqtt.md`. **Kobra 3 und Kobra S1** — dieselbe gkapi-API (`:18910` / MQTTS `:9883`). Gemessen 15.10.2026: S1 `modelId` 20025 / `state: free`, K3 `modelId` 20024; `project: null` zählt nicht als busy. Abräumen-Ablauf bleibt nur Kobra 3. + +| Schritt | Verhalten in Grotti | +| --- | --- | +| Vor MQTT-Start | `ensure_task_free`: `info/query`; wenn `state` nicht free oder `project` gesetzt → MQTT-stop, bis zu 3× pollen | +| Vor Moonraker-`print/start` | dieselbe Hygiene best-effort (Rinkhals wandelt Start oft intern zu MQTT um) | +| gkapi kurz weg | `print_info` → `None`, negativer Cache ~45 s; Start über Moonraker läuft weiter | +| Report **10101** | einmal cancel + 1,5 s Pause + erneuter Start | +| Report **10107** | klare Filament-Meldung | +| Live | Snapshot-Feld `anycubic_task` (`state`, `task_id`, `filename`) aus gecachtem `print_info` (TTL ~10 s); Wand zeigt die Zeile | + +Codes und Retry: `grotti/anycubic_lan.py` (`message_for_code`, `ensure_task_free`, `start_print`). + ### Report-Timeout `kobra.py` wartet max. 30 s auf `print/report`. Nach Bettnetz/`auto_leveling` kann der Report später kommen, der Druck trotzdem laufen (`info/query` → `project.task_id`). Grotti prüft bei Timeout deshalb `print_info` (MQTT `info/query`). diff --git a/docs/Senden-Queue.md b/docs/Senden-Queue.md index bf02f8a..e5bc88f 100644 --- a/docs/Senden-Queue.md +++ b/docs/Senden-Queue.md @@ -1,6 +1,6 @@ # Grotti: Senden und Start dauert -Stand **14.10.2026** (Stack-Quellen 15.10.2026 in [Anycubic-Stack](Anycubic-Stack)). Spulen kommen vom Ziel-Drucker zur Startzeit (`ace_map` + eingelegte Rolle), nicht aus einem freien Dropdown. `sends` speichert `ace_map` und Bettnetz für „Nochmal“. +Stand **15.10.2026** (Stack-Quellen und Task-Hygiene in [Anycubic-Stack](Anycubic-Stack)). Spulen kommen vom Ziel-Drucker zur Startzeit (`ace_map` + eingelegte Rolle), nicht aus einem freien Dropdown. `sends` speichert `ace_map` und Bettnetz für „Nochmal“. ## Warum Sekunden / Minuten @@ -71,6 +71,10 @@ Credentials holt Grotti ohne SSH: `GET :18910/info` → signiertes `POST :18910/ **Abbruch:** Grotti `abbrechen` = Moonraker `/printer/print/cancel` **plus** MQTT `action: stop` / `taskid: "-1"` (gleicher Topic wie Start). Nur Moonraker-cancel lässt den Anycubic-Task stehen → nächster MQTT-Start mit **10101**. 13.10.2026: Gridfinity auf da26 so hängen geblieben, per MQTT-stop freigemacht (`data.state: free`). +### Task-Hygiene vor dem Start (15.10.2026) + +Vor jedem Start (MQTT und reiner Moonraker-Pfad) räumt Grotti einen hängenden gkapi-Task best-effort — **Kobra 3 und S1**: `print_info` → ggf. MQTT-stop → poll. Kommt trotzdem **10101**, einmal cancel + 1,5 s + Retry (wie Rinkhals). Ist gkapi kurz unerreichbar, läuft der Moonraker-Start trotzdem (negativer Cache ~45 s). Live zeigt die Wand `anycubic_task` aus demselben `info/query` (Cache ~10 s). Details: [Anycubic-Stack](Anycubic-Stack) Abschnitt Task-Hygiene. + **Start-Report verspätet:** 13.10.2026 ~21:35, A2 (`10.10.1.59`), Ablauf 2/5 direkt nach der Bettnetz-Prüfung. Innerhalb von 30 s kam kein `print/report`, der Druck lief trotzdem an. `info/query` (Topic `…/slicer/printer/{modelId}/{deviceId}/info`, Antwort auf `…/info/report`) zeigte `project.task_id` = Grotti-`taskid`, `state: auto_leveling`. Moonraker meldet in dieser Phase noch `print_stats.state: complete` vom Vorauftrag. Grotti prüft deshalb bei einem Start-Timeout bis zu dreimal `info/query`. Steht dort die eigene `taskid`, gilt der Start als erfolgreich (`anycubic_lan.print_info`). Fallback ohne MQTT: global in Rinkhals Touch → Manage apps → 40-moonraker → Auto leveling (gilt dann für alle Moonraker-Starts inkl. Mainsail/Orca). diff --git a/grotti/anycubic_lan.py b/grotti/anycubic_lan.py index 9aac824..aa07aa4 100644 --- a/grotti/anycubic_lan.py +++ b/grotti/anycubic_lan.py @@ -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} diff --git a/grotti/moonraker.py b/grotti/moonraker.py index 2b14d21..e672b5a 100644 --- a/grotti/moonraker.py +++ b/grotti/moonraker.py @@ -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 diff --git a/grotti/version.py b/grotti/version.py index cbe92f9..3d6fa4e 100644 --- a/grotti/version.py +++ b/grotti/version.py @@ -1,3 +1,3 @@ """Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen.""" -__version__ = "2.0.0" +__version__ = "2.1.0" diff --git a/tests/test_anycubic_lan.py b/tests/test_anycubic_lan.py new file mode 100644 index 0000000..fde5d77 --- /dev/null +++ b/tests/test_anycubic_lan.py @@ -0,0 +1,195 @@ +"""Unit-Tests für Anycubic-MQTT-Codes, Task-Hygiene und 10101-Retry.""" + +import sys +import time +import unittest +from pathlib import Path +from unittest import mock + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from grotti import anycubic_lan +from grotti.anycubic_lan import ( + CODE_FILAMENT, + CODE_TASK_ACTIVE, + LanError, + ensure_task_free, + message_for_code, + start_print, + task_summary, +) + + +class MessageForCodeTests(unittest.TestCase): + def test_10101(self): + text = message_for_code(CODE_TASK_ACTIVE) + self.assertIn("10101", text) + self.assertIn("aktiv", text.lower()) + + def test_10107(self): + text = message_for_code(CODE_FILAMENT) + self.assertIn("10107", text) + self.assertIn("Filament", text) + + def test_unknown_with_default(self): + text = message_for_code(99999, "Druckstart abgelehnt") + self.assertIn("99999", text) + self.assertIn("Druckstart abgelehnt", text) + + def test_string_code(self): + self.assertEqual( + message_for_code("10107"), + message_for_code(10107), + ) + + +class ReportErrorTests(unittest.TestCase): + def test_report_carries_code(self): + text, code = anycubic_lan._report_error( + {"code": 10101, "msg": "Print task already exists"}, + "Druckstart abgelehnt", + ) + self.assertEqual(code, 10101) + self.assertIn("10101", text) + + def test_raw_msg_unknown_code(self): + text, code = anycubic_lan._report_error( + {"code": 42, "msg": "irgendwas"}, + "Druckstart abgelehnt", + ) + self.assertEqual(code, 42) + self.assertIn("irgendwas", text) + self.assertIn("42", text) + + +class TaskSummaryTests(unittest.TestCase): + def test_free(self): + summary = task_summary({"state": "free", "project": {}}) + self.assertEqual(summary["state"], "free") + self.assertIsNone(summary["task_id"]) + + def test_busy_project(self): + summary = task_summary({ + "state": "printing", + "project": {"task_id": 12345, "filename": "/tmp/foo.gcode"}, + }) + self.assertEqual(summary["task_id"], "12345") + self.assertEqual(summary["filename"], "foo.gcode") + + def test_empty(self): + self.assertIsNone(task_summary({})) + self.assertIsNone(task_summary(None)) + + +class TaskBusyTests(unittest.TestCase): + def test_free_idle(self): + self.assertFalse(anycubic_lan._task_busy({"state": "free"})) + self.assertFalse(anycubic_lan._task_busy({"state": "idle"})) + + def test_s1_style_null_project(self): + # S1 liefert project: null + last_project; last_project zählt nicht. + self.assertFalse(anycubic_lan._task_busy({ + "state": "free", + "project": None, + "last_project": {"task_id": "old"}, + })) + + def test_busy_state(self): + self.assertTrue(anycubic_lan._task_busy({"state": "printing"})) + self.assertTrue(anycubic_lan._task_busy({"state": "auto_leveling"})) + + def test_busy_project(self): + self.assertTrue(anycubic_lan._task_busy({ + "state": "free", + "project": {"task_id": "9"}, + })) + + +class PrintInfoSoftFailTests(unittest.TestCase): + def tearDown(self): + anycubic_lan._FAIL_CACHE.clear() + anycubic_lan._INFO_CACHE.clear() + + @mock.patch("grotti.anycubic_lan.credentials", side_effect=LanError("weg")) + def test_credentials_fail_returns_none(self, _cred): + self.assertIsNone(anycubic_lan.print_info("10.0.0.9", use_cache=False)) + self.assertIn("10.0.0.9", anycubic_lan._FAIL_CACHE) + + @mock.patch("grotti.anycubic_lan.credentials", side_effect=LanError("weg")) + def test_fail_cache_skips_retry(self, cred): + anycubic_lan._FAIL_CACHE["10.0.0.9"] = time.time() + self.assertIsNone(anycubic_lan.print_info("10.0.0.9", use_cache=True)) + cred.assert_not_called() + + @mock.patch("grotti.anycubic_lan.print_info", return_value=None) + def test_ensure_free_when_lan_down(self, _info): + self.assertIsNone(ensure_task_free("10.0.0.9")) + + +class EnsureTaskFreeTests(unittest.TestCase): + @mock.patch("grotti.anycubic_lan.time.sleep", return_value=None) + @mock.patch("grotti.anycubic_lan.cancel_print") + @mock.patch("grotti.anycubic_lan.print_info") + def test_already_free(self, print_info, cancel_print, _sleep): + print_info.return_value = {"state": "free", "project": {}} + info = ensure_task_free("10.0.0.1") + self.assertEqual(info["state"], "free") + cancel_print.assert_not_called() + + @mock.patch("grotti.anycubic_lan.time.sleep", return_value=None) + @mock.patch("grotti.anycubic_lan.cancel_print") + @mock.patch("grotti.anycubic_lan.print_info") + def test_clears_busy(self, print_info, cancel_print, _sleep): + print_info.side_effect = [ + {"state": "printing", "project": {"task_id": "1"}}, + {"state": "free", "project": {}}, + ] + info = ensure_task_free("10.0.0.1", attempts=3, wait_s=0.01) + self.assertEqual(info["state"], "free") + cancel_print.assert_called_once() + + @mock.patch("grotti.anycubic_lan.time.sleep", return_value=None) + @mock.patch("grotti.anycubic_lan.cancel_print") + @mock.patch("grotti.anycubic_lan.print_info") + def test_raises_when_stuck(self, print_info, cancel_print, _sleep): + print_info.return_value = {"state": "printing", "project": {"task_id": "1"}} + with self.assertRaises(LanError) as ctx: + ensure_task_free("10.0.0.1", attempts=2, wait_s=0.01) + self.assertEqual(cancel_print.call_count, 2) + self.assertIn("freimachen", ctx.exception.message) + + +class StartPrintRetryTests(unittest.TestCase): + @mock.patch("grotti.anycubic_lan.time.sleep", return_value=None) + @mock.patch("grotti.anycubic_lan.ensure_task_free") + @mock.patch("grotti.anycubic_lan.cancel_print") + @mock.patch("grotti.anycubic_lan._mqtt_print") + def test_retry_on_10101(self, mqtt_print, cancel_print, ensure_free, _sleep): + ensure_free.return_value = {"state": "free"} + mqtt_print.side_effect = [ + LanError(message_for_code(CODE_TASK_ACTIVE), code=CODE_TASK_ACTIVE), + {"state": "printing"}, + ] + result = start_print("10.0.0.1", "demo.gcode", auto_leveling=False) + self.assertEqual(result["filename"], "demo.gcode") + self.assertEqual(mqtt_print.call_count, 2) + cancel_print.assert_called_once() + + @mock.patch("grotti.anycubic_lan.time.sleep", return_value=None) + @mock.patch("grotti.anycubic_lan.ensure_task_free") + @mock.patch("grotti.anycubic_lan._mqtt_print") + def test_no_retry_on_filament(self, mqtt_print, ensure_free, _sleep): + ensure_free.return_value = {"state": "free"} + mqtt_print.side_effect = LanError( + message_for_code(CODE_FILAMENT), + code=CODE_FILAMENT, + ) + with self.assertRaises(LanError) as ctx: + start_print("10.0.0.1", "demo.gcode") + self.assertEqual(ctx.exception.code, CODE_FILAMENT) + self.assertEqual(mqtt_print.call_count, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/templates/wand.html b/web/templates/wand.html index 127001c..049120a 100644 --- a/web/templates/wand.html +++ b/web/templates/wand.html @@ -435,11 +435,26 @@ function factsHtml(printer) { ${chamberFan ? "" + icon("fan-chamber") + " Bauraum " + chamberFan + "" : ""} ${auxFan ? "" + icon("fan-aux") + " Aux " + auxFan + "" : ""} ${speed ? "Speed " + speed + "" : ""} + ${anycubicTaskLine(printer)} Host ${escapeHtml(printer.host)} ${printer.message && printer.ok === false ? `
${escapeHtml(printer.message)}
` : ""} `; } +function anycubicTaskLine(printer) { + const task = printer.anycubic_task; + if (!task || typeof task !== "object") return ""; + const state = String(task.state || "").trim(); + const tid = task.task_id != null && String(task.task_id).trim() ? String(task.task_id) : ""; + const file = task.filename ? String(task.filename) : ""; + if (!state && !tid && !file) return ""; + const bits = []; + if (state) bits.push(state); + if (tid) bits.push("task " + tid); + if (file) bits.push(file); + return "Anycubic " + escapeHtml(bits.join(" · ")) + ""; +} + function jobLineVisible(printer, state) { return !!printer.filename && state !== "standby" && state !== "offline"; }