Spoolman leads CC2 Control's copies; new spools from the bridge
- Master data of CC2 Control's copies follows Spoolman (name, brand, colour, diameter, density, net, tare, price; not the material, which was fitted to the tray). - A loaded spool's rest in CC2 Control is set to Spoolman's via /api/spools/adjust once everything is booked (old copy after the roll printed in the other printer, weigh-in in Spoolman); the resulting correction is not booked back. - Copies of spools archived or deleted in Spoolman are deleted from CC2 Control when no tray holds them (library limit 128); an outage never counts as deleted. - A roll that ran out is archived in Spoolman (setting, default on); a scan of an archived spool maps nothing. - A roll scanned again with unchanged tag data and put back into its old slot (after printing in the other printer) is recognised. - Tag generator: "Neue Spule" creates spools in Spoolman from an existing or a new filament (and vendor), labelled vendor + id, then opens the tag. 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
d03668e3c5
commit
b37cd64909
13 files changed
+873
-27
No files matched your search
+64
-2
@@ -52,6 +52,7 @@ class FakeCC2:
|
||||
self.log: list[dict] = [] # newest first, as CC2 Control lists it
|
||||
self.calls: list[tuple] = []
|
||||
self.fail = False
|
||||
self.clock = None # set by tests that need log times for adjust
|
||||
self._ids = (f"s{i:04d}" for i in itertools.count(1))
|
||||
|
||||
def _check(self):
|
||||
@@ -78,8 +79,11 @@ class FakeCC2:
|
||||
self.calls.append(("save", dict(fields)))
|
||||
if fields.get("id"):
|
||||
sp = next(s for s in self.library if s["id"] == fields["id"])
|
||||
if "archived" in fields:
|
||||
sp["archived"] = fields["archived"] in ("1", 1, True)
|
||||
for key, value in fields.items():
|
||||
if key == "archived":
|
||||
sp["archived"] = value in ("1", 1, True)
|
||||
elif key != "id":
|
||||
sp[key] = value
|
||||
return {"ok": True, "id": sp["id"]}
|
||||
sp = {"id": next(self._ids), "name": fields.get("name", ""), "brand": fields.get("brand", ""),
|
||||
"material": fields["material"], "color": fields["color"], "diameter": fields.get("diameter", 1.75),
|
||||
@@ -89,6 +93,18 @@ class FakeCC2:
|
||||
self.library.append(sp)
|
||||
return {"ok": True, "id": sp["id"]}
|
||||
|
||||
def adjust(self, cc2_id, remaining):
|
||||
self._check()
|
||||
self.calls.append(("adjust", cc2_id, remaining))
|
||||
self.weigh(cc2_id, remaining, self.clock() if self.clock else 0)
|
||||
return {"ok": True, "remaining": remaining}
|
||||
|
||||
def delete(self, cc2_id):
|
||||
self._check()
|
||||
self.calls.append(("delete", cc2_id))
|
||||
self.library = [s for s in self.library if s["id"] != cc2_id]
|
||||
return {"ok": True}
|
||||
|
||||
def assign(self, slot, spool_id):
|
||||
self._check()
|
||||
self.calls.append(("assign", slot, spool_id))
|
||||
@@ -173,6 +189,11 @@ class FakeSpoolman:
|
||||
self.calls.append(("extra", sid, dict(body["extra"])))
|
||||
if "location" in body:
|
||||
sp["location"] = body["location"]
|
||||
if "lot_nr" in body:
|
||||
sp["lot_nr"] = body["lot_nr"]
|
||||
if "archived" in body:
|
||||
self.calls.append(("archived", sid, body["archived"]))
|
||||
sp["archived"] = bool(body["archived"])
|
||||
for key, value in (body.get("extra") or {}).items():
|
||||
if value is None:
|
||||
sp["extra"].pop(key, None)
|
||||
@@ -184,6 +205,47 @@ class FakeSpoolman:
|
||||
self._check()
|
||||
return [{"key": k, **v} for k, v in self.field_defs.items()]
|
||||
|
||||
vendor_list: list | None = None
|
||||
filament_list: list | None = None
|
||||
|
||||
def vendors(self):
|
||||
self._check()
|
||||
if self.vendor_list is None:
|
||||
self.vendor_list = [{"id": 1, "name": "SUNLU"}]
|
||||
return copy.deepcopy(self.vendor_list)
|
||||
|
||||
def filaments(self):
|
||||
self._check()
|
||||
if self.filament_list is None:
|
||||
self.filament_list = [{"id": 1, "name": "PLA+ Black", "vendor": {"id": 1, "name": "SUNLU"}, "material": "PLA+",
|
||||
"color_hex": "000000", "density": 1.24, "diameter": 1.75, "weight": 1000.0}]
|
||||
return copy.deepcopy(self.filament_list)
|
||||
|
||||
def create_vendor(self, body):
|
||||
self._check()
|
||||
self.calls.append(("create_vendor", dict(body)))
|
||||
v = {"id": len(self.vendors()) + 1, **body}
|
||||
self.vendor_list.append(v)
|
||||
return v
|
||||
|
||||
def create_filament(self, body):
|
||||
self._check()
|
||||
self.calls.append(("create_filament", dict(body)))
|
||||
vendor = next(v for v in self.vendors() if v["id"] == body["vendor_id"])
|
||||
f = {"id": len(self.filaments()) + 1, "vendor": vendor, **body}
|
||||
self.filament_list.append(f)
|
||||
return f
|
||||
|
||||
def create_spool(self, body):
|
||||
self._check()
|
||||
self.calls.append(("create_spool", dict(body)))
|
||||
sid = max(self.db, default=0) + 1
|
||||
fil = next(f for f in self.filaments() if f["id"] == body["filament_id"])
|
||||
self.db[sid] = {"id": sid, "lot_nr": None, "remaining_weight": body.get("initial_weight") or fil.get("weight"),
|
||||
"initial_weight": body.get("initial_weight"), "used_weight": 0.0, "location": body.get("location", ""),
|
||||
"archived": False, "extra": {}, "filament": fil}
|
||||
return copy.deepcopy(self.db[sid])
|
||||
|
||||
def add_field(self, key, params):
|
||||
self._check()
|
||||
self.calls.append(("field", key))
|
||||
|
||||
+163
-4
@@ -93,8 +93,10 @@ def test_existing_mirror_is_reused(env):
|
||||
env["cc2"].calls.clear()
|
||||
insert(env, 0, 5)
|
||||
poll(env)
|
||||
assert [c for c in env["cc2"].calls if c[0] == "save"] == []
|
||||
assert [c for c in env["cc2"].calls if c[0] == "save" and "id" not in c[1]] == [] # not created again
|
||||
assert ("assign", 0, "s0001") in env["cc2"].calls
|
||||
# the copy made by hand lacked brand and empty weight: taken from Spoolman
|
||||
assert ("save", {"id": "s0001", "brand": "SUNLU", "tare": 150.0}) in env["cc2"].calls
|
||||
|
||||
|
||||
def test_unknown_spool_id_is_ignored(env):
|
||||
@@ -526,7 +528,7 @@ def test_migration_from_v1(tmp_path):
|
||||
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
|
||||
assert not [c for c in cc2.calls if c[0] == "assign" or (c[0] == "save" and "id" not in c[1])] # nothing re-created
|
||||
|
||||
|
||||
# ---------- v3: putting rolls back, CC2 Control's log ----------
|
||||
@@ -743,8 +745,9 @@ def test_runout_and_backup_roll(env):
|
||||
assert uses(env) == [("use", 8, 12.0)]
|
||||
assert ("used", 5, 1000.0) in env["sm"].calls # ran out: Spoolman's rest is 0, whatever the count said
|
||||
assert env["sm"].db[5]["remaining_weight"] == 0
|
||||
assert any("Rolle leergelaufen" in m for m in msgs(env))
|
||||
assert booked(env)["s0001"]["last"]["grams"] == 40.0
|
||||
assert any("Rolle leergelaufen" in m and "archivieren" in m for m in msgs(env))
|
||||
assert env["sm"].db[5]["archived"] # empty: archived in Spoolman ...
|
||||
assert ("delete", "s0001") in env["cc2"].calls # ... and its copy leaves CC2 Control
|
||||
|
||||
|
||||
def test_runout_sets_spoolman_to_zero_even_if_the_mirror_was_off(env):
|
||||
@@ -825,3 +828,159 @@ def test_printer_names_written_are_remembered(env):
|
||||
insert(env, 0, 5)
|
||||
poll(env)
|
||||
assert env["bridge"].state.data["written_printers"] == ["Rechts"]
|
||||
|
||||
|
||||
def test_rest_follows_spoolman_when_roll_comes_back(tmp_path):
|
||||
e = make_env(tmp_path, printers=2)
|
||||
for c in e["cc2s"].values():
|
||||
c.clock = e["clock"]
|
||||
e["sm"].add(5, remaining=800)
|
||||
insert(e, 0, 5)
|
||||
poll(e, 2)
|
||||
take_out_of_tray(e, 0)
|
||||
e["readers"]["p1"].record["channels0"] = channel("")
|
||||
insert(e, 1, 5, pid="p2") # into the other printer: a fresh copy there with 800 g
|
||||
poll(e, 6)
|
||||
e["cc2s"]["p2"].consume("s0001", 100.0)
|
||||
poll(e, 15)
|
||||
assert e["sm"].db[5]["remaining_weight"] == 700
|
||||
take_out_of_tray(e, 1, pid="p2")
|
||||
e["readers"]["p2"].record["channels1"] = channel("")
|
||||
insert(e, 0, 5) # back into the first printer: its old copy still says 800 g
|
||||
poll(e, 30)
|
||||
assert ("adjust", "s0001", 700.0) in e["cc2s"]["p1"].calls
|
||||
assert e["cc2s"]["p1"].library[0]["remaining"] == 700.0
|
||||
assert [c for c in e["sm"].calls if c[0] in ("use", "used")] == [("use", 5, 100.0)] # nothing booked back
|
||||
poll(e, 30)
|
||||
assert len([c for c in e["cc2s"]["p1"].calls if c[0] == "adjust"]) == 1
|
||||
|
||||
|
||||
def test_weighed_in_spoolman_goes_to_cc2(env):
|
||||
env["cc2"].clock = env["clock"]
|
||||
env["sm"].add(5, remaining=800)
|
||||
insert(env, 0, 5)
|
||||
poll(env, 2)
|
||||
env["sm"].db[5]["remaining_weight"] = 612.0 # weighed and entered in Spoolman
|
||||
poll(env, 30)
|
||||
assert ("adjust", "s0001", 612.0) in env["cc2"].calls
|
||||
assert not [c for c in env["sm"].calls if c[0] in ("use", "used")]
|
||||
assert booked(env)["s0001"]["remaining"] == 612.0 and "adjusted" not in booked(env)["s0001"]
|
||||
|
||||
|
||||
def test_rest_not_adjusted_before_use_is_booked(env):
|
||||
env["sm"].add(5, remaining=800)
|
||||
insert(env, 0, 5)
|
||||
poll(env, 2)
|
||||
env["cc2"].job = {"active": True}
|
||||
env["cc2"].consume("s0001", 50.0)
|
||||
poll(env, 20) # printing: neither booked nor adjusted
|
||||
assert not [c for c in env["cc2"].calls if c[0] == "adjust"]
|
||||
env["cc2"].job = {"active": False}
|
||||
poll(env, 30)
|
||||
assert uses(env) == [("use", 5, 50.0)]
|
||||
assert not [c for c in env["cc2"].calls if c[0] == "adjust"]
|
||||
|
||||
|
||||
def test_master_data_follows_spoolman(env):
|
||||
env["sm"].add(5, color="4A4A4A")
|
||||
insert(env, 0, 5)
|
||||
poll(env, 2)
|
||||
env["sm"].db[5]["filament"]["color_hex"] = "898989"
|
||||
env["sm"].db[5]["filament"]["density"] = 1.3
|
||||
env["bridge"].spool_cache.clear()
|
||||
poll(env, 14)
|
||||
assert ("save", {"id": "s0001", "color": "#898989", "density": 1.3}) in env["cc2"].calls
|
||||
assert env["cc2"].library[0]["color"] == "#898989"
|
||||
poll(env, 14)
|
||||
assert len([c for c in env["cc2"].calls if c[0] == "save" and "id" in c[1]]) == 1
|
||||
|
||||
|
||||
def test_copies_of_archived_or_deleted_spools_are_removed_when_not_loaded(env):
|
||||
env["sm"].add(5)
|
||||
env["sm"].add(6)
|
||||
env["sm"].add(7)
|
||||
insert(env, 0, 5)
|
||||
insert(env, 1, 6)
|
||||
insert(env, 2, 7)
|
||||
poll(env, 2)
|
||||
take_out_of_tray(env, 0)
|
||||
take_out_of_tray(env, 1)
|
||||
poll(env, 6)
|
||||
env["sm"].db[5]["archived"] = True
|
||||
del env["sm"].db[6]
|
||||
env["sm"].db[7]["archived"] = True # still in a tray: kept
|
||||
env["bridge"].spool_cache.clear()
|
||||
poll(env, 14)
|
||||
deleted = [c[1] for c in env["cc2"].calls if c[0] == "delete"]
|
||||
assert deleted == ["s0001", "s0002"]
|
||||
assert [s["id"] for s in env["cc2"].library] == ["s0003"]
|
||||
|
||||
|
||||
def test_scan_of_archived_spool_is_not_mapped(env):
|
||||
env["sm"].add(5)["archived"] = True
|
||||
insert(env, 0, 5)
|
||||
poll(env, 2)
|
||||
assert slots(env) == {} and not env["cc2"].calls
|
||||
assert any("ist archiviert" in m for m in msgs(env))
|
||||
|
||||
|
||||
def test_runout_does_not_archive_when_switched_off(env):
|
||||
env["settings"].save({"archive_empty": False})
|
||||
env["sm"].add(5, remaining=40)
|
||||
insert(env, 0, 5)
|
||||
poll(env)
|
||||
cc2 = env["cc2"]
|
||||
cc2.job = {"active": True}
|
||||
cc2.consume("s0001", 40.0)
|
||||
poll(env)
|
||||
cc2.slots[0]["printer"] = {"status": 0}
|
||||
cc2.slots[0]["spool"] = ""
|
||||
cc2.add_log(env["clock"](), "runout", "s0001", 0, 0.0, 0, "vase.gcode", "runout")
|
||||
cc2.job = {"active": False}
|
||||
poll(env, 15)
|
||||
assert env["sm"].db[5]["remaining_weight"] == 0 and not env["sm"].db[5]["archived"]
|
||||
|
||||
|
||||
def test_roll_back_in_its_old_slot_after_the_other_printer(tmp_path):
|
||||
"""Scanned again with identical tag data: the record does not change, CC2 Control asks; the roll is recognised."""
|
||||
e = make_env(tmp_path, printers=2)
|
||||
e["sm"].add(5, remaining=800)
|
||||
insert(e, 3, 5)
|
||||
poll(e, 2)
|
||||
take_out_of_tray(e, 3)
|
||||
insert(e, 0, 5, pid="p2") # scanned and loaded in the other printer
|
||||
poll(e, 6)
|
||||
assert slots(e, "p2")["0"]["spool"] == 5 and slots(e) == {}
|
||||
take_out_of_tray(e, 0, pid="p2")
|
||||
e["readers"]["p2"].record["channels0"] = channel("")
|
||||
poll(e, 6)
|
||||
assert slots(e, "p2") == {}
|
||||
put_back(e, 3) # scanned at the side again (same data) and back into its old tray
|
||||
poll(e, 2)
|
||||
assert slots(e)["3"]["spool"] == 5 and slots(e)["3"].get("reinserted")
|
||||
assert e["cc2s"]["p1"].slots[3]["spool"] == "s0001"
|
||||
|
||||
|
||||
def test_archived_spool_with_roll_in_tray_is_not_deleted(env):
|
||||
env["sm"].add(5)
|
||||
insert(env, 0, 5)
|
||||
poll(env, 2)
|
||||
env["bridge"].release(env["bridge"].workers["p1"], 0, "Test")
|
||||
env["cc2"].slots[0]["spool"] = "" # unbound, roll still in the tray with its tag
|
||||
env["sm"].db[5]["archived"] = True
|
||||
env["bridge"].spool_cache.clear()
|
||||
poll(env, 14)
|
||||
assert not [c for c in env["cc2"].calls if c[0] == "delete"]
|
||||
|
||||
|
||||
def test_spoolman_outage_does_not_delete_copies(env):
|
||||
env["sm"].add(5)
|
||||
insert(env, 0, 5)
|
||||
poll(env, 2)
|
||||
take_out_of_tray(env, 0)
|
||||
poll(env, 6)
|
||||
env["bridge"].spool_cache.clear()
|
||||
env["sm"].fail = True
|
||||
poll(env, 14)
|
||||
assert not [c for c in env["cc2"].calls if c[0] == "delete"]
|
||||
assert len(env["cc2"].library) == 1
|
||||
@@ -133,3 +133,34 @@ def test_printers_editable(tmp_path):
|
||||
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}
|
||||
|
||||
|
||||
def test_create_spools_from_existing_filament(tmp_path):
|
||||
c, bridge = make_client(tmp_path)
|
||||
sm = bridge.spoolman_factory(None)
|
||||
cat = c.get("/api/spoolman/catalog").json()
|
||||
assert cat["next_id"] == 6 and cat["filaments"][0]["name"] == "PLA+ Black"
|
||||
body = {"filament_id": 1, "count": 2, "weight": 1000, "spool_weight": 130}
|
||||
assert c.post("/api/spoolman/create", json=body, headers=H).status_code == 409 # dry-run
|
||||
assert not [x for x in sm.calls if x[0].startswith("create")]
|
||||
c.post("/api/settings", json={"dry_run": False}, headers=H)
|
||||
r = c.post("/api/spoolman/create", json=body, headers=H)
|
||||
assert r.status_code == 200 and r.json()["spools"] == [{"id": 6, "label": "SUNLU006"}, {"id": 7, "label": "SUNLU007"}]
|
||||
assert sm.db[7]["lot_nr"] == "SUNLU007" and sm.db[7]["location"] == "Lager" and sm.db[7]["initial_weight"] == 1000
|
||||
assert ("create_spool", {"filament_id": 1, "location": "Lager", "initial_weight": 1000.0, "spool_weight": 130.0}) in sm.calls
|
||||
|
||||
|
||||
def test_create_spool_with_new_filament_and_vendor(tmp_path):
|
||||
c, bridge = make_client(tmp_path)
|
||||
sm = bridge.spoolman_factory(None)
|
||||
c.post("/api/settings", json={"dry_run": False}, headers=H)
|
||||
body = {"vendor": "Elegoo", "name": "PLA Matte Black", "material": "PLA Matte", "color": "#1a1a1a", "density": 1.24,
|
||||
"diameter": 1.75, "weight": 1000, "spool_weight": 154, "extruder_temp": 210, "bed_temp": 60, "count": 1}
|
||||
r = c.post("/api/spoolman/create", json=body, headers=H)
|
||||
assert r.status_code == 200 and r.json()["spools"][0]["label"] == "ELEGOO006"
|
||||
fil = [x[1] for x in sm.calls if x[0] == "create_filament"][0]
|
||||
assert fil["color_hex"] == "1A1A1A" and fil["settings_extruder_temp"] == 210 and fil["vendor_id"] == 2
|
||||
assert ("create_vendor", {"name": "Elegoo", "empty_spool_weight": 154.0}) in sm.calls
|
||||
bad = dict(body, color="red")
|
||||
assert c.post("/api/spoolman/create", json=bad, headers=H).status_code == 400
|
||||
assert c.post("/api/spoolman/create", json=dict(body, count=50), headers=H).status_code == 400
|
||||
Reference in new issue
Block a user