- Master data of CC2 Control's copies follows Spoolman (name, brand, colour, diameter, density, net, tare, price; not the material, which was fitted to the tray). - A loaded spool's rest in CC2 Control is set to Spoolman's via /api/spools/adjust once everything is booked (old copy after the roll printed in the other printer, weigh-in in Spoolman); the resulting correction is not booked back. - Copies of spools archived or deleted in Spoolman are deleted from CC2 Control when no tray holds them (library limit 128); an outage never counts as deleted. - A roll that ran out is archived in Spoolman (setting, default on); a scan of an archived spool maps nothing. - A roll scanned again with unchanged tag data and put back into its old slot (after printing in the other printer) is recognised. - Tag generator: "Neue Spule" creates spools in Spoolman from an existing or a new filament (and vendor), labelled vendor + id, then opens the tag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014GcEk5tkfFSYD6NTF66NkC
271 lines
10 KiB
Python
271 lines
10 KiB
Python
"""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.print_info = {"machine": {"progress": 40}, "print": {"current_layer": 12, "total_layers": 30, "remaining": 600}}
|
|
self.log: list[dict] = [] # newest first, as CC2 Control lists it
|
|
self.calls: list[tuple] = []
|
|
self.fail = False
|
|
self.clock = None # set by tests that need log times for adjust
|
|
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": self.log}
|
|
|
|
def spools(self):
|
|
self._check()
|
|
return copy.deepcopy(self.spools_json())
|
|
|
|
def printer(self):
|
|
self._check()
|
|
return copy.deepcopy(self.print_info)
|
|
|
|
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"])
|
|
for key, value in fields.items():
|
|
if key == "archived":
|
|
sp["archived"] = value in ("1", 1, True)
|
|
elif key != "id":
|
|
sp[key] = value
|
|
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 adjust(self, cc2_id, remaining):
|
|
self._check()
|
|
self.calls.append(("adjust", cc2_id, remaining))
|
|
self.weigh(cc2_id, remaining, self.clock() if self.clock else 0)
|
|
return {"ok": True, "remaining": remaining}
|
|
|
|
def delete(self, cc2_id):
|
|
self._check()
|
|
self.calls.append(("delete", cc2_id))
|
|
self.library = [s for s in self.library if s["id"] != cc2_id]
|
|
return {"ok": True}
|
|
|
|
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 add_log(self, time, kind="print", spool="", slot=0, grams=0.0, mm=0.0, job="", result=""):
|
|
self.log.insert(0, {"time": int(time), "spool": spool, "slot": slot, "kind": kind, "grams": grams, "mm": mm,
|
|
"job": job, "result": result})
|
|
|
|
def weigh(self, cc2_id, remaining, time):
|
|
sp = next(s for s in self.library if s["id"] == cc2_id)
|
|
self.add_log(time, "correction", cc2_id, -1, remaining - sp["remaining"], 0, "", "weighed")
|
|
sp["remaining"] = remaining
|
|
|
|
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
|
|
self.field_defs: dict[str, dict] = {}
|
|
|
|
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, "extra": {},
|
|
"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 update_spool(self, sid, body):
|
|
self._check()
|
|
if sid not in self.db:
|
|
raise SpoolmanNotFound("nicht gefunden")
|
|
sp = self.db[sid]
|
|
for key, value in (body.get("extra") or {}).items():
|
|
if value is not None and key not in self.field_defs:
|
|
raise SpoolmanError("Spoolman PATCH: HTTP 400")
|
|
if "location" in body:
|
|
self.calls.append(("location", sid, body.get("location")))
|
|
if "used_weight" in body:
|
|
self.calls.append(("used", sid, body["used_weight"]))
|
|
sp["used_weight"] = body["used_weight"]
|
|
sp["remaining_weight"] = sp["initial_weight"] - body["used_weight"]
|
|
if "extra" in body:
|
|
self.calls.append(("extra", sid, dict(body["extra"])))
|
|
if "location" in body:
|
|
sp["location"] = body["location"]
|
|
if "lot_nr" in body:
|
|
sp["lot_nr"] = body["lot_nr"]
|
|
if "archived" in body:
|
|
self.calls.append(("archived", sid, body["archived"]))
|
|
sp["archived"] = bool(body["archived"])
|
|
for key, value in (body.get("extra") or {}).items():
|
|
if value is None:
|
|
sp["extra"].pop(key, None)
|
|
else:
|
|
sp["extra"][key] = value
|
|
return sp
|
|
|
|
def fields(self):
|
|
self._check()
|
|
return [{"key": k, **v} for k, v in self.field_defs.items()]
|
|
|
|
vendor_list: list | None = None
|
|
filament_list: list | None = None
|
|
|
|
def vendors(self):
|
|
self._check()
|
|
if self.vendor_list is None:
|
|
self.vendor_list = [{"id": 1, "name": "SUNLU"}]
|
|
return copy.deepcopy(self.vendor_list)
|
|
|
|
def filaments(self):
|
|
self._check()
|
|
if self.filament_list is None:
|
|
self.filament_list = [{"id": 1, "name": "PLA+ Black", "vendor": {"id": 1, "name": "SUNLU"}, "material": "PLA+",
|
|
"color_hex": "000000", "density": 1.24, "diameter": 1.75, "weight": 1000.0}]
|
|
return copy.deepcopy(self.filament_list)
|
|
|
|
def create_vendor(self, body):
|
|
self._check()
|
|
self.calls.append(("create_vendor", dict(body)))
|
|
v = {"id": len(self.vendors()) + 1, **body}
|
|
self.vendor_list.append(v)
|
|
return v
|
|
|
|
def create_filament(self, body):
|
|
self._check()
|
|
self.calls.append(("create_filament", dict(body)))
|
|
vendor = next(v for v in self.vendors() if v["id"] == body["vendor_id"])
|
|
f = {"id": len(self.filaments()) + 1, "vendor": vendor, **body}
|
|
self.filament_list.append(f)
|
|
return f
|
|
|
|
def create_spool(self, body):
|
|
self._check()
|
|
self.calls.append(("create_spool", dict(body)))
|
|
sid = max(self.db, default=0) + 1
|
|
fil = next(f for f in self.filaments() if f["id"] == body["filament_id"])
|
|
self.db[sid] = {"id": sid, "lot_nr": None, "remaining_weight": body.get("initial_weight") or fil.get("weight"),
|
|
"initial_weight": body.get("initial_weight"), "used_weight": 0.0, "location": body.get("location", ""),
|
|
"archived": False, "extra": {}, "filament": fil}
|
|
return copy.deepcopy(self.db[sid])
|
|
|
|
def add_field(self, key, params):
|
|
self._check()
|
|
self.calls.append(("field", key))
|
|
self.field_defs[key] = dict(params)
|
|
return self.fields()
|
|
|
|
def extra(self, sid):
|
|
import json
|
|
return {k: json.loads(v) for k, v in self.db[sid]["extra"].items()}
|
|
|
|
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
|