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
+436
-36
No files matched your search
+3
-1
@@ -6,8 +6,10 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo
|
|||||||
|
|
||||||
### Neu
|
### 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).
|
- 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.
|
- Doku [Glossar](docs/Glossar.md): Moonraker, Mainsail, gklib, Rinkhals, OctoPrint und Nachbarn.
|
||||||
|
|
||||||
## [2.0.0] – 2026-10-15
|
## [2.0.0] – 2026-10-15
|
||||||
|
|||||||
@@ -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.
|
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
|
### 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`).
|
`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`).
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Grotti: Senden und Start dauert
|
# 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
|
## 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`).
|
**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`).
|
**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).
|
Fallback ohne MQTT: global in Rinkhals Touch → Manage apps → 40-moonraker → Auto leveling (gilt dann für alle Moonraker-Starts inkl. Mainsail/Orca).
|
||||||
+180
-31
@@ -24,13 +24,23 @@ from cryptography.hazmat.primitives import padding
|
|||||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||||
|
|
||||||
CRED_TTL = 300
|
CRED_TTL = 300
|
||||||
|
INFO_TTL = 10
|
||||||
|
FAIL_TTL = 45
|
||||||
_CRED_CACHE = {}
|
_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):
|
class LanError(Exception):
|
||||||
def __init__(self, message):
|
def __init__(self, message, code=None):
|
||||||
super().__init__(message)
|
super().__init__(message)
|
||||||
self.message = message
|
self.message = message
|
||||||
|
self.code = code
|
||||||
|
|
||||||
|
|
||||||
def _info(host):
|
def _info(host):
|
||||||
@@ -141,17 +151,103 @@ def normalize_ams(ams_settings):
|
|||||||
return {"use_ams": True, "ams_box_mapping": entries}
|
return {"use_ams": True, "ams_box_mapping": entries}
|
||||||
|
|
||||||
|
|
||||||
def _report_error(body, default_message):
|
def _parse_code(value):
|
||||||
code = body.get("code")
|
if value is None or value == "":
|
||||||
if code == 10107:
|
return None
|
||||||
return "Filament gerissen. Bitte neues laden. (code 10107)"
|
try:
|
||||||
if code == 10101:
|
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 (
|
return (
|
||||||
"Alter Druckauftrag noch aktiv (Anycubic-MQTT). "
|
"Alter Druckauftrag noch aktiv (Anycubic-Task). "
|
||||||
"Bitte zuerst abbrechen. (code 10101)"
|
"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),
|
||||||
)
|
)
|
||||||
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."):
|
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,
|
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):
|
def on_connect(client, userdata, flags, reason_code, properties=None):
|
||||||
if getattr(reason_code, "is_failure", False):
|
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 "")
|
report = str(body.get("state") or "")
|
||||||
action = str(request.get("action") or "")
|
action = str(request.get("action") or "")
|
||||||
if action == "start" and report in ("failed", "stoped", "stopped"):
|
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",):
|
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
|
# start: heating / leveling / printing / preheating = angenommen
|
||||||
# stop: stoped / stopped / done = angenommen
|
# stop: stoped / stopped / done = angenommen
|
||||||
state["done"] = True
|
state["done"] = True
|
||||||
@@ -221,16 +321,26 @@ def _mqtt_print(host, request, timeout=30, timeout_message="Timeout bei MQTT-Dru
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
if state["error"]:
|
if state["error"]:
|
||||||
raise LanError(state["error"])
|
raise LanError(state["error"], code=state.get("code"))
|
||||||
return state["report"]
|
return state["report"]
|
||||||
|
|
||||||
|
|
||||||
def start_print(host, filename, auto_leveling=True, ams_settings=None, timeout=30):
|
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]
|
name = (filename or "").replace("\\", "/").split("/")[-1]
|
||||||
if not name:
|
if not name:
|
||||||
raise LanError("Dateiname fehlt.")
|
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 = {
|
data = {
|
||||||
"filename": name,
|
"filename": name,
|
||||||
"filepath": "/",
|
"filepath": "/",
|
||||||
@@ -243,10 +353,8 @@ def start_print(host, filename, auto_leveling=True, ams_settings=None, timeout=3
|
|||||||
"flow_calibration": 0,
|
"flow_calibration": 0,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
ams = normalize_ams(ams_settings)
|
|
||||||
if ams:
|
if ams:
|
||||||
data["ams_settings"] = ams
|
data["ams_settings"] = ams
|
||||||
|
|
||||||
request = {
|
request = {
|
||||||
"type": "print",
|
"type": "print",
|
||||||
"action": "start",
|
"action": "start",
|
||||||
@@ -254,26 +362,52 @@ def start_print(host, filename, auto_leveling=True, ams_settings=None, timeout=3
|
|||||||
"timestamp": int(time.time() * 1000),
|
"timestamp": int(time.time() * 1000),
|
||||||
"data": data,
|
"data": data,
|
||||||
}
|
}
|
||||||
timeout_message = "Timeout beim MQTT-Druckstart."
|
|
||||||
try:
|
try:
|
||||||
_mqtt_print(host, request, timeout=timeout, timeout_message=timeout_message)
|
_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)}
|
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):
|
def print_info(host, timeout=10, use_cache=True):
|
||||||
"""MQTT info/query, liefert data aus info/report (project.task_id, state …)."""
|
"""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)
|
cred = credentials(host)
|
||||||
|
except LanError:
|
||||||
|
_FAIL_CACHE[host] = time.time()
|
||||||
|
return None
|
||||||
|
|
||||||
model_id = cred["modelId"]
|
model_id = cred["modelId"]
|
||||||
device_id = cred["deviceId"]
|
device_id = cred["deviceId"]
|
||||||
topic_query = "anycubic/anycubicCloud/v1/slicer/printer/%s/%s/info" % (model_id, device_id)
|
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:
|
try:
|
||||||
client.connect(host, 9883, keepalive=30)
|
client.connect(host, 9883, keepalive=30)
|
||||||
except OSError:
|
except OSError:
|
||||||
|
_FAIL_CACHE[host] = time.time()
|
||||||
return None
|
return None
|
||||||
deadline = time.time() + max(3, int(timeout))
|
deadline = time.time() + max(3, int(timeout))
|
||||||
try:
|
try:
|
||||||
@@ -326,9 +461,22 @@ def print_info(host, timeout=10):
|
|||||||
client.disconnect()
|
client.disconnect()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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"]
|
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):
|
def cancel_print(host, timeout=15):
|
||||||
"""MQTT-Stop wie Rinkhals Docs: action stop, taskid -1 (Anycubic-Task)."""
|
"""MQTT-Stop wie Rinkhals Docs: action stop, taskid -1 (Anycubic-Task)."""
|
||||||
request = {
|
request = {
|
||||||
@@ -344,4 +492,5 @@ def cancel_print(host, timeout=15):
|
|||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
timeout_message="Timeout beim MQTT-Druckabbruch.",
|
timeout_message="Timeout beim MQTT-Druckabbruch.",
|
||||||
)
|
)
|
||||||
|
_INFO_CACHE.pop(host, None)
|
||||||
return {"mqtt": "stop", "report": report}
|
return {"mqtt": "stop", "report": report}
|
||||||
+22
-2
@@ -246,12 +246,14 @@ def start_print(host, filename, bed_leveling=False, ams_settings=None):
|
|||||||
MQTT selbst (auto_leveling aus App-Property; ams_settings aus .acm /
|
MQTT selbst (auto_leveling aus App-Property; ams_settings aus .acm /
|
||||||
Identity-Fallback). Bei bed_leveling oder gesetzter Grotti-ams_settings
|
Identity-Fallback). Bei bed_leveling oder gesetzter Grotti-ams_settings
|
||||||
startet Grotti per MQTTS :9883 und legt die Map explizit in die Payload.
|
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)
|
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
|
from grotti import anycubic_lan
|
||||||
|
|
||||||
|
if use_mqtt:
|
||||||
try:
|
try:
|
||||||
return anycubic_lan.start_print(
|
return anycubic_lan.start_print(
|
||||||
host,
|
host,
|
||||||
@@ -262,6 +264,12 @@ def start_print(host, filename, bed_leveling=False, ams_settings=None):
|
|||||||
except anycubic_lan.LanError as exc:
|
except anycubic_lan.LanError as exc:
|
||||||
raise PrinterError(exc.message) from exc
|
raise PrinterError(exc.message) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
anycubic_lan.ensure_task_free(host)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return post_json(host, "/printer/print/start", {"filename": filename})
|
||||||
|
|
||||||
|
|
||||||
def command(host, action):
|
def command(host, action):
|
||||||
path = {
|
path = {
|
||||||
@@ -908,9 +916,20 @@ def query(host):
|
|||||||
"extrude_factor": extrude_factor,
|
"extrude_factor": extrude_factor,
|
||||||
"slots": slots(host, status),
|
"slots": slots(host, status),
|
||||||
"exclude": exclude_status(host),
|
"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 snapshot(printers):
|
||||||
def one(printer):
|
def one(printer):
|
||||||
card = {
|
card = {
|
||||||
@@ -935,6 +954,7 @@ def snapshot(printers):
|
|||||||
"speed_factor": None,
|
"speed_factor": None,
|
||||||
"extrude_factor": None,
|
"extrude_factor": None,
|
||||||
"slots": [],
|
"slots": [],
|
||||||
|
"anycubic_task": None,
|
||||||
})
|
})
|
||||||
card.update(mesh_fields(printer["host"]))
|
card.update(mesh_fields(printer["host"]))
|
||||||
return card
|
return card
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
|
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
|
||||||
|
|
||||||
__version__ = "2.0.0"
|
__version__ = "2.1.0"
|
||||||
@@ -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()
|
||||||
@@ -435,11 +435,26 @@ function factsHtml(printer) {
|
|||||||
${chamberFan ? "<span class=\"fact-ico\">" + icon("fan-chamber") + " Bauraum <b>" + chamberFan + "</b></span>" : ""}
|
${chamberFan ? "<span class=\"fact-ico\">" + icon("fan-chamber") + " Bauraum <b>" + chamberFan + "</b></span>" : ""}
|
||||||
${auxFan ? "<span class=\"fact-ico\">" + icon("fan-aux") + " Aux <b>" + auxFan + "</b></span>" : ""}
|
${auxFan ? "<span class=\"fact-ico\">" + icon("fan-aux") + " Aux <b>" + auxFan + "</b></span>" : ""}
|
||||||
${speed ? "<span>Speed <b>" + speed + "</b></span>" : ""}
|
${speed ? "<span>Speed <b>" + speed + "</b></span>" : ""}
|
||||||
|
${anycubicTaskLine(printer)}
|
||||||
<span>Host <b>${escapeHtml(printer.host)}</b></span>
|
<span>Host <b>${escapeHtml(printer.host)}</b></span>
|
||||||
${printer.message && printer.ok === false ? `<p class="msg">${escapeHtml(printer.message)}</p>` : ""}
|
${printer.message && printer.ok === false ? `<p class="msg">${escapeHtml(printer.message)}</p>` : ""}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 "<span>Anycubic <b>" + escapeHtml(bits.join(" · ")) + "</b></span>";
|
||||||
|
}
|
||||||
|
|
||||||
function jobLineVisible(printer, state) {
|
function jobLineVisible(printer, state) {
|
||||||
return !!printer.filename && state !== "standby" && state !== "offline";
|
return !!printer.filename && state !== "standby" && state !== "offline";
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user