Multiple printers, printer/slot in Spoolman, kept in sync
- One worker per printer (name, IP, CC2 Control URL, own pinned host key); a spool sits in at most one slot of one printer, moves between printers - Spoolman: location "<printer> Slot N" plus extra fields "drucker"/"slot" (created by the bridge), cleared and "Lager" on removal - Full comparison every minute: the printer wins over manual edits of loaded spools; stale printer locations go to storage; own locations untouched - Automatic migration of v1 settings, state and host key - Status hint when a tag is still in a tray but unmapped (e.g. after "Take out" in CC2 Control) - SSH test: connection drops during exec give a clean error, not a 500 - Mock environment with several printers; tests for all of the above Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014GcEk5tkfFSYD6NTF66NkC
This commit is contained in:
1 parent
bf32667f77
commit
a9041eb538
15 files changed
+1400
-684
No files matched your search
+34
-5
@@ -113,12 +113,13 @@ class FakeSpoolman:
|
||||
self.calls: list[tuple] = []
|
||||
self.fail = False
|
||||
self.fail_use_after_apply = False
|
||||
self.field_defs: dict[str, dict] = {}
|
||||
|
||||
def add(self, sid, vendor="SUNLU", material="PLA+", color="000000", remaining=800.0, lot=None, **extra):
|
||||
self.db[sid] = {
|
||||
"id": sid, "lot_nr": lot if lot is not None else f"{vendor.upper()}{sid:03d}",
|
||||
"remaining_weight": remaining, "initial_weight": 1000.0, "spool_weight": 150.0, "used_weight": 0.0,
|
||||
"location": "", "archived": False,
|
||||
"location": "", "archived": False, "extra": {},
|
||||
"filament": {"id": sid, "name": f"{material} {color}", "material": material, "color_hex": color,
|
||||
"diameter": 1.75, "density": 1.24, "weight": 1000.0, "vendor": {"name": vendor}},
|
||||
**extra,
|
||||
@@ -139,11 +140,39 @@ class FakeSpoolman:
|
||||
self._check()
|
||||
return [copy.deepcopy(s) for s in self.db.values() if archived or not s["archived"]]
|
||||
|
||||
def set_location(self, sid, location):
|
||||
def update_spool(self, sid, body):
|
||||
self._check()
|
||||
self.calls.append(("location", sid, location))
|
||||
self.db[sid]["location"] = location
|
||||
return self.db[sid]
|
||||
if sid not in self.db:
|
||||
raise SpoolmanNotFound("nicht gefunden")
|
||||
sp = self.db[sid]
|
||||
for key, value in (body.get("extra") or {}).items():
|
||||
if value is not None and key not in self.field_defs:
|
||||
raise SpoolmanError("Spoolman PATCH: HTTP 400")
|
||||
self.calls.append(("location", sid, body.get("location")))
|
||||
if "extra" in body:
|
||||
self.calls.append(("extra", sid, dict(body["extra"])))
|
||||
if "location" in body:
|
||||
sp["location"] = body["location"]
|
||||
for key, value in (body.get("extra") or {}).items():
|
||||
if value is None:
|
||||
sp["extra"].pop(key, None)
|
||||
else:
|
||||
sp["extra"][key] = value
|
||||
return sp
|
||||
|
||||
def fields(self):
|
||||
self._check()
|
||||
return [{"key": k, **v} for k, v in self.field_defs.items()]
|
||||
|
||||
def add_field(self, key, params):
|
||||
self._check()
|
||||
self.calls.append(("field", key))
|
||||
self.field_defs[key] = dict(params)
|
||||
return self.fields()
|
||||
|
||||
def extra(self, sid):
|
||||
import json
|
||||
return {k: json.loads(v) for k, v in self.db[sid]["extra"].items()}
|
||||
|
||||
def use(self, sid, grams):
|
||||
self._check()
|
||||
|
||||
+226
-38
@@ -25,24 +25,40 @@ def env(tmp_path):
|
||||
return make_env(tmp_path)
|
||||
|
||||
|
||||
def make_env(tmp_path, dry_run=False, reader=None, cc2=None, spoolman=None, clock=None):
|
||||
PRINTERS = [
|
||||
{"id": "p1", "name": "Rechts", "host": "10.0.0.1", "cc2_url": "http://10.0.0.1:8081"},
|
||||
{"id": "p2", "name": "Links", "host": "10.0.0.2", "cc2_url": "http://10.0.0.2:8081"},
|
||||
]
|
||||
|
||||
|
||||
def make_env(tmp_path, dry_run=False, reader=None, cc2=None, spoolman=None, clock=None, printers=1,
|
||||
readers=None, cc2s=None):
|
||||
settings = SettingsStore(tmp_path, env={})
|
||||
settings.save({"dry_run": dry_run, "settle_seconds": 30, "empty_grace": 20})
|
||||
reader = reader or FakeReader()
|
||||
cc2 = cc2 or FakeCC2()
|
||||
settings.save({"printers": PRINTERS[:printers], "dry_run": dry_run, "settle_seconds": 30, "empty_grace": 20})
|
||||
readers = readers or {"p1": reader or FakeReader(), "p2": FakeReader()}
|
||||
cc2s = cc2s or {"p1": cc2 or FakeCC2(), "p2": FakeCC2()}
|
||||
sm = spoolman or FakeSpoolman()
|
||||
clock = clock or Clock()
|
||||
bridge = Bridge(tmp_path, settings, KeyStore(tmp_path), reader_factory=lambda s: reader,
|
||||
cc2_factory=lambda s: cc2, spoolman_factory=lambda s: sm, clock=clock)
|
||||
return {"bridge": bridge, "reader": reader, "cc2": cc2, "sm": sm, "clock": clock, "settings": settings,
|
||||
"path": tmp_path}
|
||||
bridge = Bridge(tmp_path, settings, KeyStore(tmp_path), reader_factory=lambda p: readers[p.id],
|
||||
cc2_factory=lambda p: cc2s[p.id], spoolman_factory=lambda s: sm, clock=clock)
|
||||
return {"bridge": bridge, "reader": readers["p1"], "cc2": cc2s["p1"], "reader2": readers["p2"],
|
||||
"cc2_2": cc2s["p2"], "readers": readers, "cc2s": cc2s, "sm": sm, "clock": clock,
|
||||
"settings": settings, "path": tmp_path}
|
||||
|
||||
|
||||
def insert(e, slot, spool_id, **kw):
|
||||
def slots(e, pid="p1"):
|
||||
return e["bridge"].state.printer(pid)["slots"]
|
||||
|
||||
|
||||
def booked(e, pid="p1"):
|
||||
return e["bridge"].state.printer(pid)["booked"]
|
||||
|
||||
|
||||
def insert(e, slot, spool_id, pid="p1", **kw):
|
||||
"""Put a tagged spool into a slot: tray loaded and the record carries the id."""
|
||||
e["cc2"].slots[slot]["printer"] = {"status": 1}
|
||||
e["cc2"].slots[slot]["question"] = "inserted"
|
||||
e["reader"].record[f"channels{slot}"] = channel(10000 + spool_id, **kw)
|
||||
e["cc2s"][pid].slots[slot]["printer"] = {"status": 1}
|
||||
e["cc2s"][pid].slots[slot]["question"] = "inserted"
|
||||
e["readers"][pid].record[f"channels{slot}"] = channel(10000 + spool_id, **kw)
|
||||
|
||||
|
||||
def poll(e, n=1, step=5):
|
||||
@@ -64,7 +80,7 @@ def test_scan_creates_assigns_and_sets_location(env):
|
||||
assert f["net"] == 1000 and f["tare"] == 150 and f["diameter"] == 1.75 and f["density"] == 1.24
|
||||
assert ("assign", 1, "s0001") in cc2.calls
|
||||
assert cc2.slots[1]["spool"] == "s0001" and cc2.slots[1]["question"] == ""
|
||||
assert ("location", 5, "CC2 Slot 2") in env["sm"].calls
|
||||
assert ("location", 5, "Rechts Slot 2") in env["sm"].calls
|
||||
poll(env, 3)
|
||||
# nothing repeated
|
||||
assert len([c for c in cc2.calls if c[0] in ("save", "assign")]) == 2
|
||||
@@ -85,7 +101,7 @@ def test_unknown_spool_id_is_ignored(env):
|
||||
insert(env, 0, 42)
|
||||
poll(env)
|
||||
assert env["cc2"].calls == []
|
||||
assert env["bridge"].state.data["slots"] == {}
|
||||
assert slots(env) == {}
|
||||
assert any("42 existiert nicht" in e["msg"] for e in env["bridge"].log.recent())
|
||||
|
||||
|
||||
@@ -93,7 +109,7 @@ def test_real_weight_tags_are_ignored(env):
|
||||
env["reader"].record["channels0"] = channel(1000)
|
||||
env["cc2"].slots[0]["printer"] = {"status": 1}
|
||||
poll(env, 2)
|
||||
assert env["cc2"].calls == [] and env["bridge"].state.data["slots"] == {}
|
||||
assert env["cc2"].calls == [] and slots(env) == {}
|
||||
|
||||
|
||||
def test_mapping_survives_weight_falling_back_to_1000(env):
|
||||
@@ -105,9 +121,9 @@ def test_mapping_survives_weight_falling_back_to_1000(env):
|
||||
env["cc2"].slots[2]["spool"] = ""
|
||||
env["cc2"].slots[2]["question"] = "changed"
|
||||
poll(env, 10)
|
||||
m = env["bridge"].state.data["slots"]["2"]
|
||||
m = slots(env)["2"]
|
||||
assert m["spool"] == 5
|
||||
assert env["sm"].db[5]["location"] == "CC2 Slot 3"
|
||||
assert env["sm"].db[5]["location"] == "Rechts Slot 3"
|
||||
|
||||
|
||||
def test_mapping_survives_restart(env):
|
||||
@@ -118,7 +134,7 @@ def test_mapping_survives_restart(env):
|
||||
poll(env)
|
||||
e2 = make_env(env["path"], reader=env["reader"], cc2=env["cc2"], spoolman=env["sm"], clock=env["clock"])
|
||||
poll(e2, 3)
|
||||
assert e2["bridge"].state.data["slots"]["0"]["spool"] == 5
|
||||
assert slots(e2)["0"]["spool"] == 5
|
||||
assert len([c for c in env["cc2"].calls if c[0] == "save"]) == 1
|
||||
|
||||
|
||||
@@ -128,9 +144,9 @@ def test_empty_slot_releases_to_storage_after_grace(env):
|
||||
poll(env)
|
||||
env["cc2"].slots[0]["printer"] = {"status": 0}
|
||||
poll(env, 3, step=5) # 0, 5, 10 s
|
||||
assert "0" in env["bridge"].state.data["slots"]
|
||||
assert "0" in slots(env)
|
||||
poll(env, 3, step=5) # >= 20 s
|
||||
assert "0" not in env["bridge"].state.data["slots"]
|
||||
assert "0" not in slots(env)
|
||||
assert env["sm"].db[5]["location"] == "Lager"
|
||||
|
||||
|
||||
@@ -140,7 +156,7 @@ def test_cc2_returning_spool_to_storage_releases(env):
|
||||
poll(env, 2)
|
||||
env["cc2"].slots[0]["spool"] = "" # CC2 gave it back to storage (no question)
|
||||
poll(env)
|
||||
assert "0" not in env["bridge"].state.data["slots"]
|
||||
assert "0" not in slots(env)
|
||||
assert env["sm"].db[5]["location"] == "Lager"
|
||||
|
||||
|
||||
@@ -151,9 +167,9 @@ def test_new_scan_with_other_id_replaces_mapping(env):
|
||||
poll(env, 2)
|
||||
insert(env, 0, 8, color="0xF88D36")
|
||||
poll(env, 2)
|
||||
assert env["bridge"].state.data["slots"]["0"]["spool"] == 8
|
||||
assert slots(env)["0"]["spool"] == 8
|
||||
assert env["sm"].db[5]["location"] == "Lager"
|
||||
assert env["sm"].db[8]["location"] == "CC2 Slot 1"
|
||||
assert env["sm"].db[8]["location"] == "Rechts Slot 1"
|
||||
assert env["cc2"].slots[0]["spool"] == "s0002"
|
||||
|
||||
|
||||
@@ -164,9 +180,9 @@ def test_spool_moved_to_other_slot(env):
|
||||
env["reader"].record["channels0"] = channel("")
|
||||
insert(env, 3, 5)
|
||||
poll(env, 2)
|
||||
slots = env["bridge"].state.data["slots"]
|
||||
assert "0" not in slots and slots["3"]["spool"] == 5
|
||||
assert env["sm"].db[5]["location"] == "CC2 Slot 4"
|
||||
sl = slots(env)
|
||||
assert "0" not in sl and sl["3"]["spool"] == 5
|
||||
assert env["sm"].db[5]["location"] == "Rechts Slot 4"
|
||||
|
||||
|
||||
def test_consumption_booked_after_print_once(env):
|
||||
@@ -216,7 +232,7 @@ def test_interrupted_booking_is_not_repeated(env):
|
||||
poll(e2, 10)
|
||||
assert [c for c in env["sm"].calls if c[0] == "use"] == [("use", 5, 4.0)]
|
||||
assert env["sm"].db[5]["used_weight"] == 4.0
|
||||
b = e2["bridge"].state.data["booked"]["s0001"]
|
||||
b = booked(e2)["s0001"]
|
||||
assert b["pending"] is None and b["remaining"] == 796.0
|
||||
|
||||
|
||||
@@ -260,7 +276,8 @@ def test_dry_run_writes_nothing(tmp_path):
|
||||
assert e["sm"].calls == []
|
||||
msgs = [x["msg"] for x in e["bridge"].log.recent(500) if x["level"] == "dry"]
|
||||
assert any("Spule SUNLU005 anlegen" in m for m in msgs)
|
||||
assert any("Ort von" in m for m in msgs)
|
||||
assert any("Ort „Rechts Slot 1“, Drucker Rechts, Slot 1" in m for m in msgs)
|
||||
assert any("Zusatzfeld" in m for m in msgs)
|
||||
assert any("10.00 g" in m for m in msgs)
|
||||
# logged once, not every cycle
|
||||
assert len([m for m in msgs if "10.00 g" in m]) == 1
|
||||
@@ -268,7 +285,7 @@ def test_dry_run_writes_nothing(tmp_path):
|
||||
e["settings"].save({"dry_run": False})
|
||||
poll(e, 10)
|
||||
assert ("use", 7, 10.0) in e["sm"].calls
|
||||
assert e["sm"].db[5]["location"] == "CC2 Slot 1"
|
||||
assert e["sm"].db[5]["location"] == "Rechts Slot 1"
|
||||
assert e["cc2"].slots[0]["spool"] == "s0002"
|
||||
|
||||
|
||||
@@ -277,11 +294,11 @@ def test_outages_do_not_lose_scans(env):
|
||||
env["sm"].fail = True
|
||||
insert(env, 0, 5)
|
||||
poll(env, 2)
|
||||
assert env["bridge"].state.data["slots"] == {}
|
||||
assert env["bridge"].state.data["scans"]
|
||||
assert slots(env) == {}
|
||||
assert env["bridge"].state.printer("p1")["scans"]
|
||||
env["sm"].fail = False
|
||||
poll(env)
|
||||
assert env["bridge"].state.data["slots"]["0"]["spool"] == 5
|
||||
assert slots(env)["0"]["spool"] == 5
|
||||
|
||||
|
||||
def test_cc2_outage_retries_assignment(env):
|
||||
@@ -300,17 +317,17 @@ def test_manual_assignment_in_cc2_is_adopted(env):
|
||||
env["cc2"].assign(3, "s0001")
|
||||
env["cc2"].slots[3]["printer"] = {"status": 1}
|
||||
poll(env)
|
||||
assert env["bridge"].state.data["slots"]["3"]["spool"] == 9
|
||||
assert env["sm"].db[9]["location"] == "CC2 Slot 4"
|
||||
assert slots(env)["3"]["spool"] == 9
|
||||
assert env["sm"].db[9]["location"] == "Rechts Slot 4"
|
||||
|
||||
|
||||
def test_enable_command_respects_dry_run(tmp_path):
|
||||
e = make_env(tmp_path, dry_run=True, cc2=FakeCC2(enabled=False))
|
||||
e["bridge"].commands.put(("enable", None))
|
||||
e["bridge"].commands.put(("enable", "p1", None))
|
||||
poll(e)
|
||||
assert e["cc2"].calls == [] and e["bridge"].snapshot["cc2_enabled"] is False
|
||||
assert e["cc2"].calls == [] and e["bridge"].snapshot["printers"][0]["cc2_enabled"] is False
|
||||
e["settings"].save({"dry_run": False})
|
||||
e["bridge"].commands.put(("enable", None))
|
||||
e["bridge"].commands.put(("enable", "p1", None))
|
||||
poll(e)
|
||||
assert e["cc2"].calls == [("enable", True)]
|
||||
|
||||
@@ -320,7 +337,7 @@ def test_state_file_is_valid_json(env):
|
||||
insert(env, 0, 5)
|
||||
poll(env)
|
||||
data = json.loads((env["path"] / "state.json").read_text())
|
||||
assert data["slots"]["0"]["spool"] == 5
|
||||
assert data["printers"]["p1"]["slots"]["0"]["spool"] == 5
|
||||
assert not list(env["path"].glob(".state.json.*"))
|
||||
|
||||
|
||||
@@ -338,3 +355,174 @@ def test_fields_use_tag_material_when_spoolman_material_does_not_fit():
|
||||
fields, notes = cc2_spool_fields(sm, channel(10003, type_="PLA", detailed="PLA Silk"))
|
||||
assert fields["material"] == "PLA Silk" and notes
|
||||
assert fields["color"] == "#FFD700"
|
||||
|
||||
|
||||
# ---------- v2: several printers, Spoolman location and extra fields ----------
|
||||
|
||||
def test_extra_fields_created_and_kept_in_sync(env):
|
||||
env["sm"].add(5)
|
||||
insert(env, 1, 5)
|
||||
poll(env)
|
||||
assert set(env["sm"].field_defs) == {"drucker", "slot"}
|
||||
assert env["sm"].field_defs["slot"]["field_type"] == "integer"
|
||||
assert env["sm"].db[5]["location"] == "Rechts Slot 2"
|
||||
assert env["sm"].extra(5) == {"drucker": "Rechts", "slot": 2}
|
||||
env["cc2"].slots[1]["spool"] = "" # "Take out" in CC2 Control
|
||||
poll(env)
|
||||
assert env["sm"].db[5]["location"] == "Lager" and env["sm"].extra(5) == {}
|
||||
|
||||
|
||||
def test_take_out_in_cc2_control_goes_to_storage(env):
|
||||
env["sm"].add(5)
|
||||
insert(env, 0, 5)
|
||||
poll(env, 2)
|
||||
env["cc2"].assign(0, None) # what the "Take out" button sends
|
||||
poll(env)
|
||||
assert slots(env) == {}
|
||||
assert env["sm"].db[5]["location"] == "Lager"
|
||||
poll(env, 20) # the unchanged tag in the record does not bring it back
|
||||
assert slots(env) == {}
|
||||
|
||||
|
||||
def test_renaming_printer_moves_location(env):
|
||||
env["sm"].add(5)
|
||||
insert(env, 0, 5)
|
||||
poll(env)
|
||||
printers = env["settings"].get().to_dict()["printers"]
|
||||
printers[0]["name"] = "Centauri Carbon 2 Rechts"
|
||||
env["settings"].save({"printers": printers})
|
||||
poll(env)
|
||||
assert env["sm"].db[5]["location"] == "Centauri Carbon 2 Rechts Slot 1"
|
||||
assert env["sm"].extra(5)["drucker"] == "Centauri Carbon 2 Rechts"
|
||||
assert slots(env)["0"]["spool"] == 5
|
||||
|
||||
|
||||
def test_manual_spoolman_edits_are_corrected(env):
|
||||
env["sm"].add(5)
|
||||
env["sm"].add(6)
|
||||
insert(env, 0, 5)
|
||||
poll(env)
|
||||
env["sm"].db[5]["location"] = "Regal 3" # someone moves a loaded spool by hand
|
||||
env["sm"].db[5]["extra"]["slot"] = "4"
|
||||
env["sm"].db[6]["location"] = "Regal 3" # a spool that is not loaded: left alone
|
||||
poll(env, 13) # >= 60 s: full comparison
|
||||
assert env["sm"].db[5]["location"] == "Rechts Slot 1" and env["sm"].extra(5)["slot"] == 1
|
||||
assert env["sm"].db[6]["location"] == "Regal 3"
|
||||
assert any("(korrigiert)" in x["msg"] for x in env["bridge"].log.recent())
|
||||
|
||||
|
||||
def test_stale_printer_locations_go_to_storage(env):
|
||||
env["sm"].add(1, location="CC2 Slot 2") # from v1 or before a state loss
|
||||
env["sm"].add(2, location="Rechts Slot 3")
|
||||
env["sm"].add(3, location="Lager", extra={"drucker": '"Links"', "slot": "1"})
|
||||
env["sm"].add(4, location="Regal 1")
|
||||
env["bridge"].state.data["written_locations"] = ["CC2 Slot 2"]
|
||||
poll(env)
|
||||
assert env["sm"].db[1]["location"] == "Lager"
|
||||
assert env["sm"].db[2]["location"] == "Lager"
|
||||
assert env["sm"].db[3]["location"] == "Lager" and env["sm"].extra(3) == {}
|
||||
assert env["sm"].db[4]["location"] == "Regal 1"
|
||||
assert not [c for c in env["sm"].calls if c[1] == 4]
|
||||
|
||||
|
||||
def test_spool_moves_between_printers(tmp_path):
|
||||
e = make_env(tmp_path, printers=2)
|
||||
e["sm"].add(5)
|
||||
insert(e, 0, 5)
|
||||
poll(e, 2)
|
||||
assert e["sm"].db[5]["location"] == "Rechts Slot 1"
|
||||
e["readers"]["p1"].record["channels0"] = channel("") # taken out of Rechts ...
|
||||
insert(e, 2, 5, pid="p2") # ... and put into Links
|
||||
poll(e, 2)
|
||||
assert slots(e, "p1") == {} and slots(e, "p2")["2"]["spool"] == 5
|
||||
assert e["sm"].db[5]["location"] == "Links Slot 3" and e["sm"].extra(5) == {"drucker": "Links", "slot": 3}
|
||||
assert e["cc2s"]["p2"].slots[2]["spool"] == "s0001"
|
||||
# Rechts' CC2 Control still binds its own mirror until the tray empties: no flip-flop
|
||||
e["cc2s"]["p1"].slots[0]["printer"] = {"status": 0}
|
||||
poll(e, 6)
|
||||
assert slots(e, "p2")["2"]["spool"] == 5 and slots(e, "p1") == {}
|
||||
assert e["sm"].db[5]["location"] == "Links Slot 3"
|
||||
|
||||
|
||||
def test_consumption_is_booked_per_printer(tmp_path):
|
||||
e = make_env(tmp_path, printers=2)
|
||||
e["sm"].add(5, remaining=800)
|
||||
e["sm"].add(8, remaining=500)
|
||||
insert(e, 0, 5)
|
||||
insert(e, 1, 8, pid="p2")
|
||||
poll(e)
|
||||
e["cc2s"]["p1"].consume("s0001", 3.0)
|
||||
e["cc2s"]["p2"].consume("s0001", 7.5) # same CC2 id in the other printer's library
|
||||
poll(e, 10)
|
||||
uses = sorted(c for c in e["sm"].calls if c[0] == "use")
|
||||
assert uses == [("use", 5, 3.0), ("use", 8, 7.5)]
|
||||
|
||||
|
||||
def test_one_printer_down_does_not_block_the_other(tmp_path):
|
||||
e = make_env(tmp_path, printers=2)
|
||||
e["sm"].add(5)
|
||||
e["readers"]["p2"].fail = True
|
||||
e["cc2s"]["p2"].fail = True
|
||||
insert(e, 0, 5)
|
||||
poll(e)
|
||||
assert e["sm"].db[5]["location"] == "Rechts Slot 1"
|
||||
snap = {p["id"]: p for p in e["bridge"].snapshot["printers"]}
|
||||
assert snap["p2"]["errors"]["ssh"] and snap["p1"]["errors"]["ssh"] is None
|
||||
|
||||
|
||||
def test_removed_printer_releases_its_spools(tmp_path):
|
||||
e = make_env(tmp_path, printers=2)
|
||||
e["sm"].add(5)
|
||||
insert(e, 0, 5, pid="p2")
|
||||
poll(e)
|
||||
assert e["sm"].db[5]["location"] == "Links Slot 1"
|
||||
e["settings"].save({"printers": PRINTERS[:1]})
|
||||
poll(e)
|
||||
assert "p2" not in e["bridge"].state.data["printers"]
|
||||
assert e["sm"].db[5]["location"] == "Lager" and e["sm"].extra(5) == {}
|
||||
|
||||
|
||||
def test_without_extra_fields_only_location(tmp_path):
|
||||
e = make_env(tmp_path)
|
||||
e["settings"].save({"spoolman_fields": False})
|
||||
e["sm"].add(5)
|
||||
insert(e, 0, 5)
|
||||
poll(e)
|
||||
assert e["sm"].db[5]["location"] == "Rechts Slot 1" and e["sm"].extra(5) == {}
|
||||
assert e["sm"].field_defs == {}
|
||||
|
||||
|
||||
def test_migration_from_v1(tmp_path):
|
||||
(tmp_path / "settings.json").write_text(json.dumps({
|
||||
"printer_host": "10.10.2.202", "ssh_port": 22, "ssh_user": "root", "cc2_url": "http://10.10.2.202:8081",
|
||||
"spoolman_url": "http://10.10.6.10:7912", "id_offset": 10000, "poll_interval": 5.0,
|
||||
"location_slot": "CC2 Slot {n}", "location_storage": "Lager", "empty_grace": 20.0,
|
||||
"settle_seconds": 60.0, "dry_run": False}))
|
||||
(tmp_path / "state.json").write_text(json.dumps({
|
||||
"version": 1,
|
||||
"slots": {"1": {"spool": 1, "name": "SUNLU001", "since": 1, "cc2": "abc", "assigned": True, "cc2_seen": True}},
|
||||
"booked": {"abc": {"spoolman": 1, "remaining": 250.0, "pending": None, "last": None}},
|
||||
"locations": {"1": {"want": "CC2 Slot 2", "done": "CC2 Slot 2"}},
|
||||
"last_record": record("", 10001), "scans": []}))
|
||||
(tmp_path / "ssh").mkdir()
|
||||
(tmp_path / "ssh" / "host_key.json").write_text('{"fingerprint": "SHA256:x"}')
|
||||
settings = SettingsStore(tmp_path, env={})
|
||||
s = settings.get()
|
||||
assert [(p.id, p.name, p.host) for p in s.printers] == [("p1", "CC2", "10.10.2.202")]
|
||||
assert s.location_slot == "{printer} Slot {n}" and s.dry_run is False
|
||||
assert "printer_host" not in json.loads((tmp_path / "settings.json").read_text())
|
||||
|
||||
reader = FakeReader(record("", 10001))
|
||||
cc2 = FakeCC2()
|
||||
cc2.library.append({"id": "abc", "name": "SUNLU001", "material": "PLA+", "color": "#000000",
|
||||
"remaining": 250.0, "note": "spoolman:1", "archived": False})
|
||||
cc2.slots[1]["spool"] = "abc"
|
||||
cc2.slots[1]["printer"] = {"status": 1}
|
||||
sm = FakeSpoolman()
|
||||
sm.add(1, remaining=250.0, location="CC2 Slot 2")
|
||||
e = make_env(tmp_path, dry_run=False, reader=reader, cc2=cc2, spoolman=sm)
|
||||
poll(e)
|
||||
assert slots(e)["1"]["spool"] == 1 and booked(e)["abc"]["remaining"] == 250.0
|
||||
assert (tmp_path / "ssh" / "host_key_p1.json").exists()
|
||||
assert sm.db[1]["location"] == "Rechts Slot 2" and sm.extra(1) == {"drucker": "Rechts", "slot": 2}
|
||||
assert not [c for c in cc2.calls if c[0] in ("save", "assign")] # nothing re-created
|
||||
@@ -107,7 +107,7 @@ def test_spoolman_requests():
|
||||
return httpx.Response(200, json={"id": 5})
|
||||
sm = Spoolman("http://10.10.6.10:7912", transport=httpx.MockTransport(handler))
|
||||
sm.spool(5)
|
||||
sm.set_location(5, "CC2 Slot 1")
|
||||
sm.update_spool(5, {"location": "CC2 Slot 1"})
|
||||
sm.use(5, 12.3456)
|
||||
assert [(r.method, r.url.path) for r in seen] == [
|
||||
("GET", "/api/v1/spool/5"), ("PATCH", "/api/v1/spool/5"), ("PUT", "/api/v1/spool/5/use")]
|
||||
|
||||
+25
-9
@@ -18,6 +18,8 @@ RECORD = record(10001, "", 1000, 10008)
|
||||
|
||||
|
||||
class Server(paramiko.ServerInterface):
|
||||
reject_exec = False
|
||||
|
||||
def __init__(self, allowed: paramiko.PKey):
|
||||
self.allowed = allowed
|
||||
self.commands = []
|
||||
@@ -34,6 +36,8 @@ class Server(paramiko.ServerInterface):
|
||||
|
||||
def check_channel_exec_request(self, channel, command):
|
||||
self.commands.append(command)
|
||||
if Server.reject_exec:
|
||||
return False
|
||||
|
||||
def reply(): # forced command: always the canvas record
|
||||
channel.sendall(json.dumps(RECORD).encode())
|
||||
@@ -102,13 +106,13 @@ def test_rejects_garbage_key(tmp_path):
|
||||
def test_first_contact_requires_confirmation(tmp_path, printer):
|
||||
keys = KeyStore(tmp_path)
|
||||
keys.save_key(printer["client_key_text"])
|
||||
reader = CanvasRecordReader(keys, "127.0.0.1", printer["port"])
|
||||
reader = CanvasRecordReader(keys, "p1", "127.0.0.1", printer["port"])
|
||||
with pytest.raises(HostKeyUnknown):
|
||||
reader.read()
|
||||
result = reader.test()
|
||||
assert result["host_key_pinned"] is False
|
||||
assert result["record"]["channels0"]["weight"] == "10001"
|
||||
keys.pin_host_key("127.0.0.1", printer["host_key"])
|
||||
keys.pin_host_key("p1", "127.0.0.1", printer["host_key"])
|
||||
rec = reader.read()
|
||||
assert rec["channels3"]["weight"] == "10008"
|
||||
# transport is reused between reads
|
||||
@@ -121,8 +125,8 @@ def test_changed_host_key_is_rejected(tmp_path, printer):
|
||||
keys = KeyStore(tmp_path)
|
||||
keys.save_key(printer["client_key_text"])
|
||||
other = paramiko.Ed25519Key.from_private_key(io.StringIO(_new_key_text()))
|
||||
keys.pin_host_key("127.0.0.1", other)
|
||||
reader = CanvasRecordReader(keys, "127.0.0.1", printer["port"])
|
||||
keys.pin_host_key("p1", "127.0.0.1", other)
|
||||
reader = CanvasRecordReader(keys, "p1", "127.0.0.1", printer["port"])
|
||||
with pytest.raises(HostKeyMismatch):
|
||||
reader.read()
|
||||
with pytest.raises(HostKeyMismatch):
|
||||
@@ -132,16 +136,16 @@ def test_changed_host_key_is_rejected(tmp_path, printer):
|
||||
def test_wrong_client_key_fails_cleanly(tmp_path, printer):
|
||||
keys = KeyStore(tmp_path)
|
||||
keys.save_key(_new_key_text())
|
||||
keys.pin_host_key("127.0.0.1", printer["host_key"])
|
||||
keys.pin_host_key("p1", "127.0.0.1", printer["host_key"])
|
||||
with pytest.raises(SSHError, match="abgelehnt|fehlgeschlagen"):
|
||||
CanvasRecordReader(keys, "127.0.0.1", printer["port"]).read()
|
||||
CanvasRecordReader(keys, "p1", "127.0.0.1", printer["port"]).read()
|
||||
|
||||
|
||||
def test_reconnects_after_drop(tmp_path, printer):
|
||||
keys = KeyStore(tmp_path)
|
||||
keys.save_key(printer["client_key_text"])
|
||||
keys.pin_host_key("127.0.0.1", printer["host_key"])
|
||||
reader = CanvasRecordReader(keys, "127.0.0.1", printer["port"])
|
||||
keys.pin_host_key("p1", "127.0.0.1", printer["host_key"])
|
||||
reader = CanvasRecordReader(keys, "p1", "127.0.0.1", printer["port"])
|
||||
reader.read()
|
||||
for t, _ in printer["servers"]:
|
||||
t.close()
|
||||
@@ -156,4 +160,16 @@ def test_unreachable_printer(tmp_path, printer):
|
||||
port = s.getsockname()[1]
|
||||
s.close()
|
||||
with pytest.raises(SSHError, match="nicht erreichbar"):
|
||||
CanvasRecordReader(keys, "127.0.0.1", port, timeout=2).read()
|
||||
CanvasRecordReader(keys, "p1", "127.0.0.1", port, timeout=2).read()
|
||||
|
||||
|
||||
def test_exec_failure_is_a_clean_error(tmp_path, printer, monkeypatch):
|
||||
monkeypatch.setattr(Server, "reject_exec", True)
|
||||
keys = KeyStore(tmp_path)
|
||||
keys.save_key(printer["client_key_text"])
|
||||
reader = CanvasRecordReader(keys, "p1", "127.0.0.1", printer["port"])
|
||||
with pytest.raises(SSHError, match="Lesefehler"):
|
||||
reader.test()
|
||||
keys.pin_host_key("p1", "127.0.0.1", printer["host_key"])
|
||||
with pytest.raises(SSHError, match="Lesefehler"):
|
||||
reader.read()
|
||||
+21
-5
@@ -67,8 +67,9 @@ def test_ssh_key_upload_never_returned(tmp_path):
|
||||
|
||||
def test_hostkey_only_from_last_test(tmp_path):
|
||||
c, _ = make_client(tmp_path)
|
||||
r = c.post("/api/ssh/hostkey", json={"fingerprint": "SHA256:forged"}, headers=H)
|
||||
r = c.post("/api/printers/p1/ssh/hostkey", json={"fingerprint": "SHA256:forged"}, headers=H)
|
||||
assert r.status_code == 400
|
||||
assert c.post("/api/printers/nope/ssh/hostkey", json={"fingerprint": "x"}, headers=H).status_code == 404
|
||||
|
||||
|
||||
def test_tag_endpoint(tmp_path):
|
||||
@@ -104,7 +105,22 @@ def test_status_and_commands(tmp_path):
|
||||
c, bridge = make_client(tmp_path)
|
||||
bridge.poll_once()
|
||||
st = c.get("/api/status").json()
|
||||
assert len(st["slots"]) == 4 and st["dry_run"] is True
|
||||
assert c.post("/api/cc2/enable", json={}, headers=H).status_code == 400
|
||||
assert c.post("/api/cc2/enable", json={"confirm": True}, headers=H).json()["dry_run"] is True
|
||||
assert c.post("/api/slots/7/release", headers=H).status_code == 404
|
||||
assert len(st["printers"]) == 1 and len(st["printers"][0]["slots"]) == 4 and st["dry_run"] is True
|
||||
assert c.post("/api/printers/p1/cc2/enable", json={}, headers=H).status_code == 400
|
||||
assert c.post("/api/printers/p1/cc2/enable", json={"confirm": True}, headers=H).json()["dry_run"] is True
|
||||
assert c.post("/api/printers/p1/slots/7/release", headers=H).status_code == 404
|
||||
assert c.post("/api/printers/zz/slots/1/release", headers=H).status_code == 404
|
||||
|
||||
|
||||
def test_printers_editable(tmp_path):
|
||||
c, _ = make_client(tmp_path)
|
||||
s = c.get("/api/settings").json()
|
||||
printers = s["printers"] + [{"name": "Links", "host": "10.10.2.235", "cc2_url": "http://10.10.2.235:8081"}]
|
||||
r = c.post("/api/settings", json={"printers": printers}, headers=H)
|
||||
assert r.status_code == 200
|
||||
got = r.json()["printers"]
|
||||
assert [p["name"] for p in got] == ["CC2", "Links"] and got[1]["id"].startswith("p") and got[1]["ssh_port"] == 22
|
||||
dup = c.post("/api/settings", json={"printers": got + [{"name": "links", "host": "x", "cc2_url": "http://x"}]}, headers=H)
|
||||
assert dup.status_code == 400
|
||||
bad = c.post("/api/settings", json={"location_slot": "Slot {n}"}, headers=H)
|
||||
assert bad.status_code == 400 # two printers need {printer}
|
||||
Reference in new issue
Block a user