CC2 ↔ Spoolman bridge with NFC tag generator
- 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:
commit
bf32667f77
32 files changed
+4216
No files matched your search
Whitespace-only changes.
+159
@@ -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
|
||||
@@ -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"
|
||||
@@ -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}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user