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:
1 parent
5081212212
commit
db6a85a703
54 files changed
+3379
-384
No files matched your search
@@ -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")
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
@@ -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"])
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user