Grotti 3.7.0: Treiber für Kobra 4 und Marlin, smartere Dateien-Auswahl und einstellbare Etiketten.

Senden und Abbruch laufen über den Treiber, Orca-Doppelzeilen und tote Ideen-API fallen weg, Drucker folgen der Datei, Etikettenformat liegt in den Einstellungen.
This commit is contained in:
Dominic Gundel committed 2026-11-02 14:03:57 +01:00
1 parent 5081212212
commit db6a85a703
54 files changed
+3379 -384

No files matched your search

+128
View File
@@ -0,0 +1,128 @@
"""Treiberwahl und die LAN-Karte. Kein echter Drucker."""
import sys
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from grotti.drivers import for_printer
from grotti.drivers.anycubic_lan import card_from_info, slots_from_report
from grotti.moonraker import PrinterError
from grotti.printers import family_from_name
class FamilyTests(unittest.TestCase):
def test_existing_names_stay_moonraker(self):
for name, family in (
("Kobra 3 A1", "kobra_3"),
("Kobra S1", "kobra_s1"),
):
self.assertEqual(family_from_name(name), family)
drv = for_printer({"name": name, "family": family, "host": "10.0.0.1"})
self.assertEqual(drv.transport, "moonraker")
self.assertTrue(drv.rinkhals)
self.assertFalse(drv.firmware_restart)
def test_kobra_4_is_lan(self):
self.assertEqual(family_from_name("Kobra 4"), "kobra_4")
drv = for_printer({"family": "kobra_4", "host": "10.0.0.4"})
self.assertEqual(drv.transport, "anycubic_lan")
self.assertFalse(drv.rinkhals)
def test_i3_is_marlin(self):
self.assertEqual(family_from_name("Anycubic i3 Mega"), "anycubic_i3")
drv = for_printer({"family": "anycubic_i3", "host": "usb"})
self.assertEqual(drv.transport, "marlin")
def test_tenlog_is_marlin_with_two_heads(self):
self.assertEqual(family_from_name("Tenlog"), "tenlog")
drv = for_printer({"family": "tenlog", "host": "COM5"})
self.assertEqual(drv.transport, "marlin")
self.assertFalse(drv.rinkhals)
class LanCardTests(unittest.TestCase):
def test_busy_with_file_is_printing(self):
card = card_from_info({
"state": "busy",
"temp": {"curr_nozzle_temp": 210, "curr_hotbed_temp": 60},
"project": {"filename": "teil.gcode", "progress": 40, "curr_layer": 3, "total_layers": 10},
})
self.assertEqual(card["state"], "printing")
self.assertEqual(card["filename"], "teil.gcode")
self.assertAlmostEqual(card["progress"], 0.4)
self.assertEqual(card["nozzle"], 210)
self.assertEqual(card["layer"], 3)
self.assertFalse(card["has_camera"])
self.assertIsNone(card["mesh"])
def test_free_is_standby(self):
card = card_from_info({"state": "free", "project": None})
self.assertEqual(card["state"], "standby")
self.assertEqual(card["filename"], "")
def test_slots_from_boxes(self):
slots = slots_from_report({
"multi_color_box": [{
"slots": [
{"type": "PETG"},
{"type": "PLA"},
{"type": ""},
{"type": "ABS"},
],
}],
})
self.assertEqual([item["type"] for item in slots], ["PETG", "PLA", "ABS"])
self.assertEqual([item["index"] for item in slots], [0, 1, 3])
class LanUploadTests(unittest.TestCase):
def test_posts_filename_and_gcode(self):
captured = {}
class Response:
def __enter__(self):
return self
def __exit__(self, *args):
return False
def read(self):
return b"{}"
def fake_urlopen(request, timeout=0):
captured["url"] = request.full_url
captured["body"] = request.data
captured["length"] = request.headers["X-file-length"]
return Response()
info = {"urls": {"fileUploadurl": "http://10.0.0.4:18910/gcode_upload?s=token"}}
drv = for_printer({"family": "kobra_4", "host": "10.0.0.4"})
with mock.patch("grotti.anycubic_lan.print_info", return_value=info), \
mock.patch("urllib.request.urlopen", fake_urlopen):
name = drv.upload("10.0.0.4", "teil.gcode", b"G1 X1")
self.assertEqual(name, "teil.gcode")
self.assertIn("/gcode_upload", captured["url"])
self.assertIn(b'name="filename"', captured["body"])
self.assertIn(b'name="gcode"', captured["body"])
self.assertIn(b"G1 X1", captured["body"])
self.assertEqual(captured["length"], "5")
def test_missing_url(self):
drv = for_printer({"family": "kobra_4"})
with mock.patch("grotti.anycubic_lan.print_info", return_value={"urls": {}}):
with self.assertRaises(PrinterError):
drv.upload("10.0.0.4", "teil.gcode", b"G1")
class MarlinStubTests(unittest.TestCase):
def test_start_says_service_missing(self):
drv = for_printer({"family": "anycubic_i3", "name": "i3", "host": "COM3"})
cards = drv.snapshot([{"name": "i3", "host": "COM3", "family": "anycubic_i3"}])
self.assertEqual(cards[0]["state"], "offline")
self.assertIn("läuft nicht", cards[0]["message"])
with self.assertRaises(PrinterError):
drv.start_print("COM3", "teil.gcode")
+2
View File
@@ -119,6 +119,8 @@ class HostValidationTests(unittest.TestCase):
self.assertEqual(validate_printer_host("10.10.1.60"), "10.10.1.60")
self.assertEqual(validate_printer_host("kobra.local:80"), "kobra.local:80")
self.assertEqual(validate_printer_host("COM5@250000"), "COM5@250000")
self.assertEqual(validate_printer_host("/dev/ttyUSB0"), "/dev/ttyUSB0")
with self.assertRaises(GrottiError):
validate_printer_host("http://evil")
with self.assertRaises(GrottiError):
+125
View File
@@ -0,0 +1,125 @@
"""Marlin-Dienst mit einer Leitung aus dem Test. Kein USB-Gerät."""
import sys
import threading
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from grotti.drivers.marlin import MarlinDriver
from grotti.marlin_daemon import serve
from grotti.marlin_link import MarlinService, gcode_lines, sd_filename, split_port
from grotti.moonraker import PrinterError
class ScriptedPort:
def __init__(self):
self.written = []
self._queue = []
def write(self, data):
text = data.decode().strip()
self.written.append(text)
if text.startswith("M27"):
self._queue.append(b"Done printing file\n")
self._queue.append(b"ok\n")
elif text.startswith("M105"):
self._queue.append(
b"ok T:200.0 /210.0 T0:200.0 /210.0 T1:190.0 /180.0 B:60.0 /60.0\n"
)
else:
self._queue.append(b"ok\n")
def readline(self):
if self._queue:
return self._queue.pop(0)
return b""
def close(self):
pass
GCODE = b"; Kommentar\nT0\nM109 S210\nT1\nM109 S180\nG1 X1\n"
class MarlinTests(unittest.TestCase):
def setUp(self):
self.port = ScriptedPort()
self.service = MarlinService(open_port=lambda device, baud: self.port)
self.httpd = serve(0, self.service)
self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
self.thread.start()
base = "http://127.0.0.1:%s" % self.httpd.server_address[1]
self.drv = MarlinDriver(base=base)
self.printer = {
"name": "Tenlog",
"host": "COM5@250000",
"family": "tenlog",
"family_label": "Tenlog",
}
def tearDown(self):
self.httpd.shutdown()
self.httpd.server_close()
def test_split_and_short_name(self):
self.assertEqual(split_port("COM5@250000"), ("COM5", 250000))
self.assertEqual(sd_filename("teil drucken.gcode"), "TEILDRUC.GCO")
self.assertEqual(gcode_lines(GCODE), ["T0", "M109 S210", "T1", "M109 S180", "G1 X1"])
def test_two_heads_and_stream(self):
card = self.drv.snapshot([self.printer])[0]
self.assertTrue(card["ok"])
self.assertEqual(card["state"], "standby")
self.assertEqual(card["slots"], [])
self.assertEqual(len(card["tools"]), 2)
self.assertEqual(card["tools"][1]["temp"], 190.0)
self.assertEqual(card["host"], "COM5@250000")
stored = self.drv.upload("COM5@250000", "teil.gcode", GCODE)
self.drv.start_print("COM5@250000", stored, mode="stream")
self.service.session("COM5")._thread.join(3)
sent = self.port.written
self.assertIn("T0", sent)
self.assertIn("T1", sent)
self.assertNotIn("M28 teil.gcode", sent)
status = self.service.session("COM5").status()
self.assertEqual(status["state"], "complete")
self.assertEqual(status["progress"], 1)
def test_sd_writes_then_starts(self):
self.drv.attach(self.printer)
stored = self.drv.upload("COM5", "teil.gcode", GCODE)
shown = self.drv.start_print("COM5@250000", stored, mode="sd")
self.assertEqual(shown, "TEIL.GCO")
self.service.session("COM5")._thread.join(5)
sent = self.port.written
self.assertIn("M28 TEIL.GCO", sent)
self.assertIn("M29", sent)
self.assertIn("M23 TEIL.GCO", sent)
self.assertIn("M24", sent)
self.assertLess(sent.index("M28 TEIL.GCO"), sent.index("T0"))
self.assertLess(sent.index("M29"), sent.index("M24"))
def test_pause_and_cancel_two_heaters(self):
self.drv.attach(self.printer)
session = self.service.session("COM5")
session.state = "printing"
session.mode = "sd"
self.drv.command("COM5", "pause")
self.assertIn("M25", self.port.written)
self.assertEqual(session.state, "paused")
session.cancel()
self.assertIn("M104 T0 S0", self.port.written)
self.assertIn("M104 T1 S0", self.port.written)
self.assertIn("M140 S0", self.port.written)
self.assertEqual(session.state, "standby")
def test_without_service_stays_offline(self):
drv = MarlinDriver(base="http://127.0.0.1:9")
card = drv.snapshot([{"name": "i3", "host": "COM9", "family": "anycubic_i3"}])[0]
self.assertFalse(card["ok"])
self.assertEqual(card["state"], "offline")
with self.assertRaises(PrinterError):
drv.start_print("COM9", "a.gcode")
+115
View File
@@ -0,0 +1,115 @@
"""Marlin-Dienst starten, ohne ihn zu beenden. Kein echtes USB-Gerät."""
import sys
import tempfile
import threading
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from grotti import marlin_supervisor as sup
class SupervisorTests(unittest.TestCase):
def tearDown(self):
sup.disable()
def test_running_service_is_not_started_again(self):
starts = []
service = sup.MarlinSupervisor(
Path("."),
healthy=lambda: True,
start=lambda folder: starts.append(folder),
wait_s=0.1,
)
result = service.ensure()
self.assertTrue(result["running"])
self.assertFalse(result["started"])
self.assertEqual(starts, [])
def test_starts_once_when_down(self):
state = {"up": False}
starts = []
def healthy():
return state["up"]
def start(folder):
starts.append(folder)
state["up"] = True
service = sup.MarlinSupervisor(Path("."), healthy=healthy, start=start, wait_s=0.2)
first = service.ensure()
second = service.ensure()
self.assertTrue(first["started"])
self.assertEqual(len(starts), 1)
self.assertFalse(second["started"])
self.assertTrue(second["running"])
def test_failed_start_does_not_repeat_immediately(self):
starts = []
service = sup.MarlinSupervisor(
Path("."),
healthy=lambda: False,
start=lambda folder: starts.append(1),
wait_s=0.05,
)
service.ensure()
service.ensure()
self.assertEqual(len(starts), 1)
def test_parallel_ensure_starts_once(self):
gate = threading.Event()
starts = []
def start(folder):
starts.append(1)
gate.wait(1)
service = sup.MarlinSupervisor(
Path("."),
healthy=lambda: False,
start=start,
wait_s=0.05,
)
threads = [threading.Thread(target=service.ensure) for _ in range(4)]
for thread in threads:
thread.start()
gate.set()
for thread in threads:
thread.join(2)
self.assertEqual(len(starts), 1)
def test_spawn_is_detached_and_runs_the_module(self):
seen = {}
class Proc:
pid = 42
def popen(**kwargs):
seen.update(kwargs)
if kwargs.get("creationflags", 0) & sup._CREATE_BREAKAWAY:
raise OSError("breakaway")
return Proc()
with tempfile.TemporaryDirectory() as folder:
pid = sup.spawn_detached(folder, popen=popen)
self.assertEqual(pid, 42)
self.assertEqual(seen["args"][1:], ["-m", "grotti.marlin_daemon"])
self.assertIs(seen["stdin"], sup.subprocess.DEVNULL)
if sup.os.name == "nt":
flags = seen["creationflags"]
self.assertTrue(flags & sup._DETACHED)
self.assertFalse(flags & sup._CREATE_BREAKAWAY)
else:
self.assertTrue(seen["start_new_session"])
def test_enable_reuses_a_live_daemon(self):
sup.enable(Path("."))
sup._supervisor.healthy = lambda: True
sup._supervisor.start = lambda folder: (_ for _ in ()).throw(AssertionError("gestartet"))
result = sup.ensure_if_enabled()
self.assertTrue(result["running"])
self.assertFalse(result["started"])
+65
View File
@@ -0,0 +1,65 @@
"""Problem-Meldungen: Relay und Antwort, ohne echten Forgejo."""
import json
import sys
import unittest
from pathlib import Path
from unittest import mock
from urllib.error import HTTPError, URLError
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from grotti import meldung
class RelayTests(unittest.TestCase):
def test_relay_ok(self):
cfg = {"_raw": {"meldung_relay": "http://127.0.0.1/hooks/grotti-meldung"}}
class Resp:
def read(self):
return b'{"url":"https://git.example/issues/1"}'
def __enter__(self):
return self
def __exit__(self, *args):
return False
with mock.patch("urllib.request.urlopen", return_value=Resp()) as opener:
ok, message = meldung.send_relay(cfg, {"text": "kaputt", "version": "1"})
self.assertTrue(ok)
self.assertIn("issues/1", message)
request = opener.call_args[0][0]
body = json.loads(request.data.decode("utf-8"))
self.assertEqual(body["text"], "kaputt")
self.assertNotIn("username", body)
def test_relay_down(self):
cfg = {"_raw": {"meldung_relay": "http://127.0.0.1:9/hooks"}}
with mock.patch("urllib.request.urlopen", side_effect=URLError("down")):
ok, message = meldung.send_relay(cfg, {"text": "x"})
self.assertFalse(ok)
self.assertIn("nicht erreichbar", message)
def test_relay_http_error(self):
cfg = {"_raw": {"meldung_relay": "http://example/hooks"}}
class Fake(HTTPError):
def __init__(self):
pass
def read(self):
return b"nope"
code = 502
with mock.patch("urllib.request.urlopen", side_effect=Fake()):
ok, message = meldung.send_relay(cfg, {"text": "x"})
self.assertFalse(ok)
self.assertIn("502", message)
if __name__ == "__main__":
unittest.main()
+20 -1
View File
@@ -155,12 +155,31 @@ class OrcaSendenHelpersTest(unittest.TestCase):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "orca-senden.json"
path.write_text(
json.dumps({"url": "http://127.0.0.1:8770/", "token": "grt_abc"}),
json.dumps({
"url": "http://127.0.0.1:8770/",
"token": "grt_abc",
"watch_dir_local": "D:/Slice",
}),
encoding="utf-8",
)
cfg = orca.load_config(path)
self.assertEqual(cfg["url"], "http://127.0.0.1:8770")
self.assertEqual(cfg["token"], "grt_abc")
self.assertEqual(cfg["watch_dir_local"], "D:/Slice")
def test_resolve_watch_dir_prefers_local(self):
with tempfile.TemporaryDirectory() as tmp:
local = Path(tmp) / "slice"
local.mkdir()
cfg = {
"url": "http://127.0.0.1:8770",
"token": "grt_x",
"watch_dir_local": str(local),
}
with patch.object(orca, "fetch_watch_dir") as fetch:
resolved = orca.resolve_watch_dir(cfg)
self.assertEqual(resolved, local)
fetch.assert_not_called()
def test_load_config_missing(self):
with tempfile.TemporaryDirectory() as tmp: