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
+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
|
||||
Reference in new issue
Block a user