CC2 ↔ Spoolman bridge with NFC tag generator
CI / test (push) Successful in 1m4s
CI / image (push) Successful in 1m1s

- 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
This commit is contained in:
Jonas MenzelandClaude Opus 5.5 committed 2026-10-25 03:01:17 +01:00
commit bf32667f77
32 files changed
+4216

No files matched your search

View File
Whitespace-only changes.
+159
View File
@@ -0,0 +1,159 @@
"""In-memory stand-ins for CC2 Control, Spoolman and the SSH record reader."""
from __future__ import annotations
import copy
import itertools
from app.cc2control import CC2Error
from app.spoolman import SpoolmanError, SpoolmanNotFound
def channel(weight="", type_="PLA", detailed="PLA+", color="0x000000"):
if weight == "":
return {k: "" for k in ("brand", "code", "color", "date", "detailed_type", "diameter", "manufacturer",
"nozzle_max_temp", "nozzle_min_temp", "type", "weight")}
return {"brand": "0xEEEEEEEE", "code": "0x0001", "color": color, "date": "2000/00", "detailed_type": detailed,
"diameter": "1.750000", "manufacturer": "ELEGOO", "nozzle_max_temp": "230", "nozzle_min_temp": "190",
"type": type_, "weight": str(weight)}
def record(*weights):
rec = {"auto_plug_in_enable": 0, "color_table_size": 0, "extern_channel": {}}
for n in range(4):
w = weights[n] if n < len(weights) else ""
rec[f"channels{n}"] = w if isinstance(w, dict) else channel(w)
return rec
class FakeReader:
def __init__(self, rec=None):
self.record = rec or record()
self.fail = False
def read(self):
from app.sshsource import SSHError
if self.fail:
raise SSHError("Drucker nicht erreichbar")
return copy.deepcopy(self.record)
def close(self):
pass
class FakeCC2:
def __init__(self, enabled=True):
self.enabled = enabled
self.slots = [{"slot": i, "spool": "", "last": "", "question": "", "printer": {"status": 0}} for i in range(5)]
self.slots[4]["printer"] = None
self.library: list[dict] = []
self.job = {"active": False}
self.calls: list[tuple] = []
self.fail = False
self._ids = (f"s{i:04d}" for i in itertools.count(1))
def _check(self):
if self.fail:
raise CC2Error("CC2 Control nicht erreichbar")
def spools_json(self):
return {"enabled": self.enabled, "slots": self.slots, "spools": self.library, "job": self.job, "log": []}
def spools(self):
self._check()
return copy.deepcopy(self.spools_json())
def canvas(self):
self._check()
return {}
def save_spool(self, fields):
self._check()
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)
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),
"density": fields.get("density", 1.24), "net": fields.get("net", 1000),
"remaining": fields.get("remaining", fields.get("net", 1000)), "tare": fields.get("tare", 0),
"low": 0, "price": 0, "note": fields.get("note", ""), "created": 0, "used": 0, "archived": False}
self.library.append(sp)
return {"ok": True, "id": sp["id"]}
def assign(self, slot, spool_id):
self._check()
self.calls.append(("assign", slot, spool_id))
for s in self.slots:
if spool_id and s["spool"] == spool_id:
s["spool"] = ""
self.slots[slot]["spool"] = spool_id or ""
self.slots[slot]["question"] = ""
return {"ok": True}
def enable(self, on):
self._check()
self.calls.append(("enable", on))
self.enabled = on
return {"ok": True, "enabled": on}
def consume(self, cc2_id, grams):
sp = next(s for s in self.library if s["id"] == cc2_id)
sp["remaining"] -= grams
def close(self):
pass
class FakeSpoolman:
def __init__(self):
self.db: dict[int, dict] = {}
self.calls: list[tuple] = []
self.fail = False
self.fail_use_after_apply = False
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,
"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,
}
return self.db[sid]
def _check(self):
if self.fail:
raise SpoolmanError("Spoolman nicht erreichbar")
def spool(self, sid):
self._check()
if sid not in self.db:
raise SpoolmanNotFound("nicht gefunden")
return copy.deepcopy(self.db[sid])
def spools(self, archived=False):
self._check()
return [copy.deepcopy(s) for s in self.db.values() if archived or not s["archived"]]
def set_location(self, sid, location):
self._check()
self.calls.append(("location", sid, location))
self.db[sid]["location"] = location
return self.db[sid]
def use(self, sid, grams):
self._check()
self.calls.append(("use", sid, round(grams, 3)))
sp = self.db[sid]
sp["used_weight"] += grams
sp["remaining_weight"] -= grams
if self.fail_use_after_apply:
raise SpoolmanError("Timeout")
return sp
def close(self):
pass
+340
View File
@@ -0,0 +1,340 @@
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"
+115
View File
@@ -0,0 +1,115 @@
import json
import httpx
import pytest
from app.cc2control import CC2Control, CC2Error, clean_text, encode_body, spoolman_id_of, tray_statuses
from app.spoolman import Spoolman
class Recorder:
def __init__(self, status=200, body=None):
self.requests: list[httpx.Request] = []
self.status = status
self.body = body if body is not None else {"ok": True}
def __call__(self, request: httpx.Request):
self.requests.append(request)
return httpx.Response(self.status, json=self.body)
def client(rec):
return CC2Control("http://10.10.2.202:8081", transport=httpx.MockTransport(rec))
def test_write_request_format():
rec = Recorder(201, {"ok": True, "id": "a1b2"})
resp = client(rec).save_spool({"name": "SUNLU005", "brand": "Sunlu", "material": "PLA+", "color": "#1E40AF",
"diameter": 1.75, "net": 1000.0, "remaining": 812.5, "note": "spoolman:5"})
assert resp == {"ok": True, "id": "a1b2"}
r = rec.requests[0]
assert r.method == "POST" and r.url.path == "/api/spools/save"
assert r.headers["host"] == "10.10.2.202:8081"
assert "origin" not in r.headers and "sec-fetch-site" not in r.headers
assert r.headers["x-cc2-request"] == "1"
assert "transfer-encoding" not in r.headers
assert r.headers["content-type"].startswith("text/plain")
assert r.content.decode() == ("name=SUNLU005\nbrand=Sunlu\nmaterial=PLA+\ncolor=#1E40AF\ndiameter=1.75\n"
"net=1000\nremaining=812.5\nnote=spoolman:5\n")
# every header only once (CC2 Control fails closed on duplicates)
names = [k.lower() for k, _ in r.headers.raw]
assert len(names) == len(set(names))
def test_get_request_has_no_write_marker_or_origin():
rec = Recorder(body={"enabled": True, "slots": [], "spools": []})
assert client(rec).spools()["enabled"] is True
r = rec.requests[0]
assert r.method == "GET" and r.url.path == "/api/spools"
assert r.headers["host"] == "10.10.2.202:8081" and "origin" not in r.headers
def test_assign_and_empty_slot():
rec = Recorder()
c = client(rec)
c.assign(2, "a1b2")
c.assign(2, None)
assert rec.requests[0].content == b"slot=2\nspool=a1b2\n"
assert rec.requests[1].content == b"slot=2\nspool=\n"
def test_enable_sends_bare_word():
rec = Recorder(body={"ok": True, "enabled": True})
client(rec).enable(True)
client(rec).enable(False)
assert rec.requests[0].url.path == "/api/spools/enable"
assert rec.requests[0].content == b"on" and rec.requests[1].content == b"off"
assert rec.requests[0].headers["x-cc2-request"] == "1"
def test_errors_are_reported_without_crash():
rec = Recorder(400, {"ok": False, "error": "Invalid spool"})
with pytest.raises(CC2Error, match="400 Invalid spool"):
client(rec).save_spool({"material": "PLA", "color": "#000000"})
def boom(request):
raise httpx.ConnectError("refused")
with pytest.raises(CC2Error, match="nicht erreichbar"):
CC2Control("http://10.10.2.202:8081", transport=httpx.MockTransport(boom)).spools()
def test_text_cleaning():
assert clean_text('Sun"lu\\ \x01PLA\n') == "Sun lu PLA"
long = "ä" * 60 # 120 bytes
out = clean_text(long, 96)
assert len(out.encode()) <= 96 and out == "ä" * 48
body = encode_body({"name": "x" * 200, "material": "M" * 50})
name, material = body.splitlines()
assert len(name) == len("name=") + 96 and len(material) == len("material=") + 32
assert encode_body({"note": "a=b", "archived": "0", "skip": None}) == "note=a=b\narchived=0\n"
def test_spoolman_note_and_trays():
assert spoolman_id_of({"note": "spoolman:17"}) == 17
assert spoolman_id_of({"note": "gekauft 2025 spoolman:3 rest"}) == 3
assert spoolman_id_of({"note": "spoolman:x"}) is None
canvas = {"telemetry": {"result": {"canvas_info": {"canvas_list": [{"tray_list": [
{"tray_id": 0, "status": 1}, {"tray_id": 1, "status": 0}, {"tray_id": 2, "status": 2}, {"tray_id": 3}]}]}}}}
assert tray_statuses(canvas) == {0: 1, 1: 0, 2: 2}
assert tray_statuses({}) == {}
def test_spoolman_requests():
seen = []
def handler(request):
seen.append(request)
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.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")]
assert json.loads(seen[1].content) == {"location": "CC2 Slot 1"}
assert json.loads(seen[2].content) == {"use_weight": 12.346}
+159
View File
@@ -0,0 +1,159 @@
"""SSH reader against a local paramiko server that behaves like the restricted printer key."""
import io
import json
import os
import socket
import stat
import threading
import paramiko
import pytest
from app.sshsource import CanvasRecordReader, HostKeyMismatch, HostKeyUnknown, KeyStore, SSHError
from .fakes import record
RECORD = record(10001, "", 1000, 10008)
class Server(paramiko.ServerInterface):
def __init__(self, allowed: paramiko.PKey):
self.allowed = allowed
self.commands = []
def check_channel_request(self, kind, chanid):
return paramiko.OPEN_SUCCEEDED if kind == "session" else paramiko.OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED
def get_allowed_auths(self, username):
return "publickey"
def check_auth_publickey(self, username, key):
ok = username == "root" and key.get_base64() == self.allowed.get_base64()
return paramiko.AUTH_SUCCESSFUL if ok else paramiko.AUTH_FAILED
def check_channel_exec_request(self, channel, command):
self.commands.append(command)
def reply(): # forced command: always the canvas record
channel.sendall(json.dumps(RECORD).encode())
channel.send_exit_status(0)
channel.close()
threading.Timer(0.05, reply).start() # after the exec request is acknowledged
return True
@pytest.fixture
def printer():
host_key = paramiko.Ed25519Key.from_private_key(io.StringIO(_new_key_text()))
client_key_text = _new_key_text()
client_key = paramiko.Ed25519Key.from_private_key(io.StringIO(client_key_text))
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
sock.listen(5)
servers = []
stop = threading.Event()
def accept():
while not stop.is_set():
try:
conn, _ = sock.accept()
except OSError:
return
t = paramiko.Transport(conn)
t.add_server_key(host_key)
srv = Server(client_key)
servers.append((t, srv))
try:
t.start_server(server=srv)
except Exception:
pass
threading.Thread(target=accept, daemon=True).start()
yield {"port": sock.getsockname()[1], "host_key": host_key, "client_key_text": client_key_text,
"servers": servers}
stop.set()
sock.close()
for t, _ in servers:
t.close()
def _new_key_text():
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
return Ed25519PrivateKey.generate().private_bytes(
serialization.Encoding.PEM, serialization.PrivateFormat.OpenSSH, serialization.NoEncryption()).decode()
def test_key_store_permissions_and_no_secret_in_info(tmp_path, printer):
keys = KeyStore(tmp_path)
keys.save_key(printer["client_key_text"])
mode = stat.S_IMODE(os.stat(keys.key_path).st_mode)
assert mode == 0o600
info = keys.key_info()
assert info["fingerprint"].startswith("SHA256:") and info["type"] == "ssh-ed25519"
assert "PRIVATE" not in json.dumps(info)
def test_rejects_garbage_key(tmp_path):
with pytest.raises(SSHError):
KeyStore(tmp_path).save_key("not a key")
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"])
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"])
rec = reader.read()
assert rec["channels3"]["weight"] == "10008"
# transport is reused between reads
reader.read()
assert len(printer["servers"]) == 3 # refused read, test(), then one transport for both reads
reader.close()
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"])
with pytest.raises(HostKeyMismatch):
reader.read()
with pytest.raises(HostKeyMismatch):
reader.test()
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"])
with pytest.raises(SSHError, match="abgelehnt|fehlgeschlagen"):
CanvasRecordReader(keys, "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"])
reader.read()
for t, _ in printer["servers"]:
t.close()
assert reader.read()["channels0"]["weight"] == "10001"
def test_unreachable_printer(tmp_path, printer):
keys = KeyStore(tmp_path)
keys.save_key(printer["client_key_text"])
s = socket.socket()
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
s.close()
with pytest.raises(SSHError, match="nicht erreichbar"):
CanvasRecordReader(keys, "127.0.0.1", port, timeout=2).read()
+118
View File
@@ -0,0 +1,118 @@
import pytest
from app import materials, tagcodec
from app.tagcodec import TagData, encode_commands, parse_commands
TEMPLATE = ("A2:04:0103A00C,A2:05:34030FD1,A2:06:010B5502,A2:07:656C6567,A2:08:6F6F2E63,A2:09:6F6DFE00,"
"A2:10:36EEEEEE,A2:11:EE000000,A2:12:00807665,A2:13:00010000,A2:14:000000FF,A2:15:00BE00E6,"
"A2:16:00000000,A2:17:00AF2711,A2:18:0036C800")
def pla_plus(spool_id, color):
return TagData(spool_id=spool_id, color=color, material="PLA", subtype=0x0001, temp_min=190, temp_max=230)
def test_template_reproduced_exactly():
assert encode_commands(pla_plus(1, "000000")) == TEMPLATE
@pytest.mark.parametrize("spool_id,color,p14,p17", [
(1, "000000", "A2:14:000000FF", "A2:17:00AF2711"),
(5, "2850DF", "A2:14:2850DFFF", "A2:17:00AF2715"),
(8, "F88D36", "A2:14:F88D36FF", "A2:17:00AF2718"),
])
def test_expected_pages(spool_id, color, p14, p17):
cmds = encode_commands(pla_plus(spool_id, color)).split(",")
assert p14 in cmds and p17 in cmds
# only pages 14 and 17 differ from the template
diff = set(cmds) ^ set(TEMPLATE.split(","))
assert {c[:5] for c in diff} <= {"A2:14", "A2:17"}
def test_never_writes_pages_0_to_3():
pages = tagcodec.encode_pages(pla_plus(1, "000000"))
assert min(pages) == 4
with pytest.raises(tagcodec.TagError):
tagcodec.pages_to_commands({3: b"\0\0\0\0"})
def test_decode_roundtrip():
d = tagcodec.decode_pages(parse_commands(TEMPLATE))
assert d["url"] == "https://www.elegoo.com"
assert d["material"] == "PLA" and d["subtype_name"] == "PLA+"
assert (d["temp_min"], d["temp_max"]) == (190, 230)
assert d["diameter"] == 1.75 and d["weight"] == 10001 and d["spool_id"] == 1
assert d["manufacturer"] == "EEEEEEEE"
def test_rapid_petg_codes():
tag = TagData(spool_id=12, color="FFFFFF", material="PETG", subtype=0x0105, temp_min=240, temp_max=255)
pages = tagcodec.encode_pages(tag)
assert pages[0x12].hex().upper() == "80698471"
assert pages[0x13].hex().upper() == "01050000"
assert pages[0x15].hex().upper() == "00F000FF"
def test_validation():
with pytest.raises(tagcodec.TagError):
encode_commands(TagData(spool_id=1, color="000000", material="PLA", subtype=0x0100)) # PETG subtype
with pytest.raises(tagcodec.TagError):
encode_commands(pla_plus(60000, "000000")) # does not fit 2 bytes
with pytest.raises(tagcodec.TagError):
encode_commands(pla_plus(1, "zzzzzz"))
with pytest.raises(tagcodec.TagError):
encode_commands(TagData(spool_id=1, color="000000", temp_min=240, temp_max=200))
def test_custom_url_fits_before_canvas_pages():
tag = pla_plus(123, "000000")
tag.url = "http://10.10.6.10:7912/spool/show/123"
pages = tagcodec.encode_pages(tag)
assert max(p for p in pages if p < 0x10) <= 0x0F
assert tagcodec.decode_pages(pages)["url"] == tag.url
tag.url = "https://example.com/" + "x" * 40
with pytest.raises(tagcodec.TagError):
tagcodec.encode_pages(tag)
@pytest.mark.parametrize("weight,expected", [
("10001", 1), ("10005", 5), ("10000", 0), ("9999", None), ("1000", None), ("", None),
("abc", None), ("70000", None), ("10008.000", 8),
])
def test_spool_id_from_weight(weight, expected):
assert tagcodec.spool_id_from_weight(weight) == expected
def test_parse_record():
rec = {"channels0": {"weight": "10001", "type": "PLA"}, "channels1": {"weight": "", "type": ""},
"channels3": {"weight": "1000", "type": "PETG"}}
slots = tagcodec.parse_record(rec)
assert [s["spool_id"] for s in slots] == [1, None, None, None]
assert [s["occupied"] for s in slots] == [True, False, False, True]
@pytest.mark.parametrize("color,expected", [
("000000", "000000"), ("2850DF", "2850DF"), ("0000FF", "2850DF"), ("0047AB", "2850DF"),
("FF8000", "F88D36"), ("FF0000", "F72221"), ("FFFFFF", "FFFFFF"), ("808080", "898989"),
("1E90FF", "48A7FA"), ("800080", "433089"), (None, "000000"), ("#ffa500", "F88D36"),
])
def test_palette_suggestion(color, expected):
assert tagcodec.nearest_palette_color(color) == expected
@pytest.mark.parametrize("material,name,mat,sub", [
("PLA+", "", "PLA", 0x0001), ("PLA", "Silk Gold", "PLA", 0x0003), ("PETG", "", "PETG", 0x0100),
("PETG", "Rapid PETG", "PETG", 0x0105), ("PLA", "Matte Black", "PLA", 0x0006), ("PETG-CF", "", "PETG", 0x0101),
("ABS", "", "ABS", 0x0200), ("TPU", "95A", "TPU", 0x0301), ("PCTG", "", "PC", 0x0601),
("ASA", "", "ASA", 0x0800), ("", "", "PLA", 0x0000), ("PLA", "Rapid PLA+", "PLA", 0x000A),
])
def test_material_suggestion(material, name, mat, sub):
s = materials.suggest(material, name)
assert (s["material"], s["subtype"]) == (mat, sub)
def test_tables_complete():
assert len(materials.MATERIALS) == 15
assert len(materials.SUBTYPES) == 50
for code, name, _ in materials.SUBTYPES:
assert materials.material_of_subtype(code) is not None, name
+110
View File
@@ -0,0 +1,110 @@
import base64
import os
import stat
import pytest
from fastapi.testclient import TestClient
from app.bridge import Bridge
from app.sshsource import KeyStore
from app.storage import SettingsStore
from app.web import create_app
from .fakes import FakeCC2, FakeReader, FakeSpoolman
from .test_ssh import _new_key_text
H = {"X-Bridge-Request": "1"}
def make_client(tmp_path, env=None):
settings = SettingsStore(tmp_path, env={})
sm = FakeSpoolman()
sm.add(5, material="PLA+", color="1E40AF")
bridge = Bridge(tmp_path, settings, KeyStore(tmp_path), reader_factory=lambda s: FakeReader(),
cc2_factory=lambda s: FakeCC2(), spoolman_factory=lambda s: sm)
app = create_app(tmp_path, bridge=bridge, start_loop=False, env=env or {})
return TestClient(app), bridge
def test_pages_render(tmp_path):
c, _ = make_client(tmp_path)
for path in ("/", "/tags", "/settings"):
r = c.get(path)
assert r.status_code == 200 and "CC2" in r.text
def test_writes_need_marker_header(tmp_path):
c, _ = make_client(tmp_path)
assert c.post("/api/settings", json={"dry_run": False}).status_code == 403
assert c.post("/api/settings", json={"dry_run": True}, headers=H).status_code == 200
def test_settings_roundtrip_and_validation(tmp_path):
c, _ = make_client(tmp_path)
s = c.get("/api/settings").json()
assert s["dry_run"] is True and s["id_offset"] == 10000
r = c.post("/api/settings", json={"poll_interval": "7", "location_storage": "Regal"}, headers=H)
assert r.json()["poll_interval"] == 7.0
assert c.get("/api/settings").json()["location_storage"] == "Regal"
assert c.post("/api/settings", json={"location_slot": "Slot"}, headers=H).status_code == 400
assert SettingsStore(tmp_path).get().location_storage == "Regal"
def test_ssh_key_upload_never_returned(tmp_path):
c, _ = make_client(tmp_path)
text = _new_key_text()
r = c.post("/api/ssh/key", json={"key": text}, headers=H)
assert r.status_code == 200
body = r.text + c.get("/api/ssh").text + c.get("/api/status").text
assert "PRIVATE KEY" not in body and text.splitlines()[1] not in body
assert r.json()["key"]["fingerprint"].startswith("SHA256:")
path = tmp_path / "ssh" / "id_key"
assert stat.S_IMODE(os.stat(path).st_mode) == 0o600
assert c.post("/api/ssh/key", json={"key": "junk"}, headers=H).status_code == 400
assert c.delete("/api/ssh/key", headers=H).json() == {"key": None}
assert not path.exists()
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)
assert r.status_code == 400
def test_tag_endpoint(tmp_path):
c, _ = make_client(tmp_path)
r = c.post("/api/tag", json={"spool_id": 5, "color": "2850DF", "material": "PLA", "subtype": 1,
"temp_min": 190, "temp_max": 230}, headers=H)
assert r.status_code == 200
d = r.json()
assert "A2:14:2850DFFF" in d["commands"] and "A2:17:00AF2715" in d["commands"]
assert d["decoded"]["spool_id"] == 5 and d["qr_svg"].startswith("<svg")
bad = c.post("/api/tag", json={"spool_id": 5, "color": "2850DF", "material": "PLA", "subtype": 0x0100,
"temp_min": 190, "temp_max": 230}, headers=H)
assert bad.status_code == 400
def test_materials_endpoint(tmp_path):
c, _ = make_client(tmp_path)
m = c.get("/api/materials").json()
assert m["materials"]["PETG"]["code"] == "80698471" and len(m["subtypes"]) == 50 and len(m["palette"]) == 24
def test_basic_auth(tmp_path):
c, _ = make_client(tmp_path, env={"BRIDGE_USER": "admin", "BRIDGE_PASSWORD": "geheim"})
assert c.get("/").status_code == 401
assert c.get("/healthz").status_code == 200
good = {"Authorization": "Basic " + base64.b64encode(b"admin:geheim").decode()}
bad = {"Authorization": "Basic " + base64.b64encode(b"admin:nope").decode()}
assert c.get("/", headers=bad).status_code == 401
assert c.get("/", headers=good).status_code == 200
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