- Reads /opt/usr/canvas_record_info.json over SSH (pinned host key, restricted key), decodes Spoolman spool ids from the tag weight - Mirrors spools into CC2 Control's spool library and assigns slots, keeps its own slot mapping, sets Spoolman locations - Books the drop of CC2 Control's measured remaining weight to Spoolman after each print, idempotent across restarts and interrupted bookings - Dry-run by default; web UI for settings, SSH key, status and log - Tag generator for NFC Tools command chains (NTAG213/215/216) - Tests, mock environment, Dockerfile, Gitea Actions CI Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014GcEk5tkfFSYD6NTF66NkC
341 lines
12 KiB
Python
341 lines
12 KiB
Python
import json
|
|
|
|
import pytest
|
|
|
|
from app.bridge import Bridge, cc2_spool_fields, material_fits
|
|
from app.sshsource import KeyStore
|
|
from app.storage import SettingsStore
|
|
|
|
from .fakes import FakeCC2, FakeReader, FakeSpoolman, channel, record
|
|
|
|
|
|
class Clock:
|
|
def __init__(self):
|
|
self.t = 1_800_000_000.0
|
|
|
|
def __call__(self):
|
|
return self.t
|
|
|
|
def tick(self, s):
|
|
self.t += s
|
|
|
|
|
|
@pytest.fixture
|
|
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):
|
|
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()
|
|
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}
|
|
|
|
|
|
def insert(e, slot, spool_id, **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)
|
|
|
|
|
|
def poll(e, n=1, step=5):
|
|
for _ in range(n):
|
|
e["bridge"].poll_once()
|
|
e["clock"].tick(step)
|
|
|
|
|
|
def test_scan_creates_assigns_and_sets_location(env):
|
|
env["sm"].add(5, vendor="Sunlu", material="PLA+", color="1E40AF", remaining=812.5)
|
|
insert(env, 1, 5)
|
|
poll(env)
|
|
cc2 = env["cc2"]
|
|
saves = [c for c in cc2.calls if c[0] == "save"]
|
|
assert len(saves) == 1
|
|
f = saves[0][1]
|
|
assert f["name"] == "SUNLU005" and f["brand"] == "Sunlu" and f["material"] == "PLA+"
|
|
assert f["color"] == "#1E40AF" and f["note"] == "spoolman:5" and f["remaining"] == 812.5
|
|
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
|
|
poll(env, 3)
|
|
# nothing repeated
|
|
assert len([c for c in cc2.calls if c[0] in ("save", "assign")]) == 2
|
|
assert len([c for c in env["sm"].calls if c[0] == "location"]) == 1
|
|
|
|
|
|
def test_existing_mirror_is_reused(env):
|
|
env["sm"].add(5)
|
|
env["cc2"].save_spool({"name": "SUNLU005", "material": "PLA+", "color": "#000000", "note": "spoolman:5"})
|
|
env["cc2"].calls.clear()
|
|
insert(env, 0, 5)
|
|
poll(env)
|
|
assert [c for c in env["cc2"].calls if c[0] == "save"] == []
|
|
assert ("assign", 0, "s0001") in env["cc2"].calls
|
|
|
|
|
|
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 any("42 existiert nicht" in e["msg"] for e in env["bridge"].log.recent())
|
|
|
|
|
|
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"] == {}
|
|
|
|
|
|
def test_mapping_survives_weight_falling_back_to_1000(env):
|
|
env["sm"].add(5)
|
|
insert(env, 2, 5)
|
|
poll(env)
|
|
# touchscreen edit: weight falls back, CC2 asks again ("changed") with no spool in the slot
|
|
env["reader"].record["channels2"] = channel(1000)
|
|
env["cc2"].slots[2]["spool"] = ""
|
|
env["cc2"].slots[2]["question"] = "changed"
|
|
poll(env, 10)
|
|
m = env["bridge"].state.data["slots"]["2"]
|
|
assert m["spool"] == 5
|
|
assert env["sm"].db[5]["location"] == "CC2 Slot 3"
|
|
|
|
|
|
def test_mapping_survives_restart(env):
|
|
env["sm"].add(5)
|
|
insert(env, 0, 5)
|
|
poll(env)
|
|
env["reader"].record["channels0"] = channel(1000)
|
|
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 len([c for c in env["cc2"].calls if c[0] == "save"]) == 1
|
|
|
|
|
|
def test_empty_slot_releases_to_storage_after_grace(env):
|
|
env["sm"].add(5)
|
|
insert(env, 0, 5)
|
|
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"]
|
|
poll(env, 3, step=5) # >= 20 s
|
|
assert "0" not in env["bridge"].state.data["slots"]
|
|
assert env["sm"].db[5]["location"] == "Lager"
|
|
|
|
|
|
def test_cc2_returning_spool_to_storage_releases(env):
|
|
env["sm"].add(5)
|
|
insert(env, 0, 5)
|
|
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 env["sm"].db[5]["location"] == "Lager"
|
|
|
|
|
|
def test_new_scan_with_other_id_replaces_mapping(env):
|
|
env["sm"].add(5)
|
|
env["sm"].add(8, color="F88D36")
|
|
insert(env, 0, 5)
|
|
poll(env, 2)
|
|
insert(env, 0, 8, color="0xF88D36")
|
|
poll(env, 2)
|
|
assert env["bridge"].state.data["slots"]["0"]["spool"] == 8
|
|
assert env["sm"].db[5]["location"] == "Lager"
|
|
assert env["sm"].db[8]["location"] == "CC2 Slot 1"
|
|
assert env["cc2"].slots[0]["spool"] == "s0002"
|
|
|
|
|
|
def test_spool_moved_to_other_slot(env):
|
|
env["sm"].add(5)
|
|
insert(env, 0, 5)
|
|
poll(env, 2)
|
|
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"
|
|
|
|
|
|
def test_consumption_booked_after_print_once(env):
|
|
env["sm"].add(5, remaining=800)
|
|
insert(env, 0, 5)
|
|
poll(env)
|
|
cc2 = env["cc2"]
|
|
cc2.job = {"active": True}
|
|
for _ in range(10):
|
|
cc2.consume("s0001", 1.25)
|
|
poll(env)
|
|
assert not [c for c in env["sm"].calls if c[0] == "use"] # not during the print
|
|
cc2.job = {"active": False}
|
|
poll(env, 3, step=5)
|
|
assert not [c for c in env["sm"].calls if c[0] == "use"] # waits for settle_seconds
|
|
poll(env, 6, step=5)
|
|
uses = [c for c in env["sm"].calls if c[0] == "use"]
|
|
assert uses == [("use", 5, 12.5)]
|
|
poll(env, 5)
|
|
assert [c for c in env["sm"].calls if c[0] == "use"] == uses
|
|
|
|
|
|
def test_no_double_booking_after_restart(env):
|
|
env["sm"].add(5, remaining=800)
|
|
insert(env, 0, 5)
|
|
poll(env)
|
|
env["cc2"].consume("s0001", 3.25)
|
|
poll(env, 10)
|
|
assert [c for c in env["sm"].calls if c[0] == "use"] == [("use", 5, 3.25)]
|
|
e2 = make_env(env["path"], reader=env["reader"], cc2=env["cc2"], spoolman=env["sm"], clock=env["clock"])
|
|
poll(e2, 10)
|
|
assert [c for c in env["sm"].calls if c[0] == "use"] == [("use", 5, 3.25)]
|
|
env["cc2"].consume("s0001", 1.0)
|
|
poll(e2, 10)
|
|
assert [c for c in env["sm"].calls if c[0] == "use"] == [("use", 5, 3.25), ("use", 5, 1.0)]
|
|
|
|
|
|
def test_interrupted_booking_is_not_repeated(env):
|
|
env["sm"].add(5, remaining=800)
|
|
insert(env, 0, 5)
|
|
poll(env)
|
|
env["cc2"].consume("s0001", 4.0)
|
|
env["sm"].fail_use_after_apply = True # PUT reached Spoolman, answer lost
|
|
poll(env, 10)
|
|
env["sm"].fail_use_after_apply = False
|
|
e2 = make_env(env["path"], reader=env["reader"], cc2=env["cc2"], spoolman=env["sm"], clock=env["clock"])
|
|
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"]
|
|
assert b["pending"] is None and b["remaining"] == 796.0
|
|
|
|
|
|
def test_interrupted_booking_retried_when_not_applied(env):
|
|
env["sm"].add(5, remaining=800)
|
|
insert(env, 0, 5)
|
|
poll(env)
|
|
env["cc2"].consume("s0001", 4.0)
|
|
poll(env, 7) # settles, but Spoolman is down when booking
|
|
env["sm"].fail = True
|
|
poll(env, 3)
|
|
env["sm"].fail = False
|
|
poll(env, 3)
|
|
assert [c for c in env["sm"].calls if c[0] == "use"] == [("use", 5, 4.0)]
|
|
|
|
|
|
def test_negative_change_logged_not_booked(env):
|
|
env["sm"].add(5, remaining=800)
|
|
insert(env, 0, 5)
|
|
poll(env)
|
|
env["cc2"].consume("s0001", -20) # weigh-in raised the remaining weight
|
|
poll(env, 10)
|
|
assert not [c for c in env["sm"].calls if c[0] == "use"]
|
|
assert any("gestiegen" in e["msg"] for e in env["bridge"].log.recent())
|
|
env["cc2"].consume("s0001", 5)
|
|
poll(env, 10)
|
|
assert [c for c in env["sm"].calls if c[0] == "use"] == [("use", 5, 5.0)]
|
|
|
|
|
|
def test_dry_run_writes_nothing(tmp_path):
|
|
e = make_env(tmp_path, dry_run=True)
|
|
e["sm"].add(5)
|
|
e["cc2"].save_spool({"name": "X", "material": "PLA", "color": "#000000", "note": "spoolman:7"})
|
|
e["sm"].add(7)
|
|
e["cc2"].calls.clear()
|
|
insert(e, 0, 5)
|
|
poll(e, 3)
|
|
e["cc2"].consume("s0001", 10)
|
|
poll(e, 20)
|
|
assert e["cc2"].calls == []
|
|
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("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
|
|
# switching dry-run off applies what is pending
|
|
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["cc2"].slots[0]["spool"] == "s0002"
|
|
|
|
|
|
def test_outages_do_not_lose_scans(env):
|
|
env["sm"].add(5)
|
|
env["sm"].fail = True
|
|
insert(env, 0, 5)
|
|
poll(env, 2)
|
|
assert env["bridge"].state.data["slots"] == {}
|
|
assert env["bridge"].state.data["scans"]
|
|
env["sm"].fail = False
|
|
poll(env)
|
|
assert env["bridge"].state.data["slots"]["0"]["spool"] == 5
|
|
|
|
|
|
def test_cc2_outage_retries_assignment(env):
|
|
env["sm"].add(5)
|
|
insert(env, 0, 5)
|
|
env["cc2"].fail = True
|
|
poll(env, 2)
|
|
env["cc2"].fail = False
|
|
poll(env, 2)
|
|
assert env["cc2"].slots[0]["spool"] == "s0001"
|
|
|
|
|
|
def test_manual_assignment_in_cc2_is_adopted(env):
|
|
env["sm"].add(9)
|
|
env["cc2"].save_spool({"name": "SUNLU009", "material": "PLA", "color": "#000000", "note": "spoolman:9"})
|
|
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"
|
|
|
|
|
|
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))
|
|
poll(e)
|
|
assert e["cc2"].calls == [] and e["bridge"].snapshot["cc2_enabled"] is False
|
|
e["settings"].save({"dry_run": False})
|
|
e["bridge"].commands.put(("enable", None))
|
|
poll(e)
|
|
assert e["cc2"].calls == [("enable", True)]
|
|
|
|
|
|
def test_state_file_is_valid_json(env):
|
|
env["sm"].add(5)
|
|
insert(env, 0, 5)
|
|
poll(env)
|
|
data = json.loads((env["path"] / "state.json").read_text())
|
|
assert data["slots"]["0"]["spool"] == 5
|
|
assert not list(env["path"].glob(".state.json.*"))
|
|
|
|
|
|
def test_material_fits():
|
|
assert material_fits("PLA+", "PLA", "PLA+")
|
|
assert material_fits("PLA Matte", "PLA", "PLA")
|
|
assert material_fits("PLA-CF", "PLA", "")
|
|
assert not material_fits("Silk PLA", "PLA", "PLA Silk")
|
|
assert material_fits("PLA Silk", "PLA", "PLA Silk")
|
|
assert not material_fits("PETG", "PLA", "PLA")
|
|
|
|
|
|
def test_fields_use_tag_material_when_spoolman_material_does_not_fit():
|
|
sm = FakeSpoolman().add(3, material="Silk PLA", color="FFD700")
|
|
fields, notes = cc2_spool_fields(sm, channel(10003, type_="PLA", detailed="PLA Silk"))
|
|
assert fields["material"] == "PLA Silk" and notes
|
|
assert fields["color"] == "#FFD700"
|