Spoolman leads CC2 Control's copies; new spools from the bridge
CI / test (push) Successful in 2m35s
CI / image (push) Successful in 42s

- 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:
Jonas MenzelandClaude Opus 5.5 committed 2026-10-25 21:26:49 +01:00
1 parent d03668e3c5
commit b37cd64909
13 files changed
+873 -27

No files matched your search

+163 -4
View File
@@ -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