Files
Jonas MenzelandClaude Opus 5.5 b37cd64909
CI / test (push) Successful in 2m35s
CI / image (push) Successful in 42s
Spoolman leads CC2 Control's copies; new spools from the bridge
- 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
2026-10-25 21:26:49 +01:00

491 lines
20 KiB
Python

"""Local stand-in for the printer (SSH), CC2 Control and Spoolman, for manual end-to-end tests.
python tools/mock_env.py --host mock --printers 2
Per printer i: SSH 2222+i (keys added via POST /authorize), CC2 Control 8081+i. Spoolman 7912, control 8099.
Control endpoints (port 8099, optional &printer=i): POST /insert?slot=&spool= (scanned roll in),
/remove?slot= (roll out, the record keeps its tag data), /putback?slot= (roll in without a scan),
/takeout?slot= (CC2 Control's "Take out"), /consume?slot=&grams=, /job?active=0|1,
/touch?slot= (touchscreen edit: weight falls back to 1000), /print?slot=&mm=&job=&result= (logged use of
a tray, charged to its spool if it has one), /weigh?spool=&remaining= (weigh-in in CC2 Control).
"""
from __future__ import annotations
import argparse
import itertools
import json
import socket
import sys
import threading
import time
from pathlib import Path
import paramiko
import uvicorn
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from tests.fakes import channel, record # noqa: E402
def printer_state():
st = {
"record": record(),
"enabled": True,
"slots": [{"slot": i, "spool": "", "last": "", "question": "", "printer": {"status": 0}} for i in range(5)],
"spools": [],
"job": {"active": False, "file": "", "slots": []},
"log": [],
"ids": (f"m{i:04d}" for i in itertools.count(1)),
}
st["slots"][4]["printer"] = None
return st
PRINTERS: list[dict] = []
STATE = {"spoolman": {}, "fields": {}, "requests": [], "vendors": [], "filaments": []}
LOCK = threading.Lock()
def seed_spoolman():
for sid, vendor, mat, color, rem in [(1, "Sunlu", "PLA+", "000000", 640.0), (5, "Sunlu", "PLA+", "1E40AF", 812.5),
(8, "Elegoo", "PLA", "FF7F00", 990.0), (12, "Elegoo", "PETG", "F5F5F5", 455.0),
(14, "Polymaker", "PLA", "C0A060", 300.0)]:
STATE["spoolman"][sid] = {
"id": sid, "lot_nr": f"{vendor.upper()}{sid:03d}", "remaining_weight": rem, "used_weight": 1000 - rem,
"initial_weight": 1000.0, "spool_weight": 160.0, "location": "Lager", "archived": False, "price": 19.9,
"extra": {},
"filament": {"id": sid, "name": f"{mat} {'Silk Gold' if sid == 14 else color}", "material": mat,
"color_hex": color, "diameter": 1.75, "density": 1.24, "weight": 1000.0,
"settings_extruder_temp": 215,
"vendor": {"id": 1 if vendor == "Sunlu" else 2 if vendor == "Elegoo" else 3, "name": vendor}},
}
# ---------- CC2 Control ----------
def make_cc2(host_header: str, P: dict) -> FastAPI:
app = FastAPI()
@app.middleware("http")
async def guard(request: Request, call_next):
raw = [k.decode().lower() for k, _ in request.scope["headers"]]
if len(raw) != len(set(raw)):
return JSONResponse({"error": "duplicate header"}, 403)
if request.headers.get("host") != host_header or "origin" in request.headers \
or "transfer-encoding" in request.headers:
return JSONResponse({"error": "guard"}, 403)
if request.method == "POST" and request.headers.get("x-cc2-request") != "1":
return JSONResponse({"error": "marker"}, 403)
return await call_next(request)
def form(body: bytes) -> dict:
text = body.decode("utf-8").rstrip("\r\n")
out = {}
for line in text.split("\n"):
if "=" not in line:
raise HTTPException(400, "Invalid form")
k, v = line.split("=", 1)
if not k or k in out or len(k) >= 16 or len(v) > 96:
raise HTTPException(400, "Invalid form")
out[k] = v
return out
@app.get("/api/spools")
def spools():
with LOCK:
STATE["requests"].append("GET /api/spools")
return {"available": True, "enabled": P["enabled"], "slots": P["slots"], "spools": P["spools"],
"job": P["job"], "log": P["log"]}
@app.get("/api/printer")
def printer_status():
active = P["job"]["active"]
return {"connected": True, "machine": {"status": 2 if active else 1, "progress": 42 if active else 0},
"print": {"filename": P["job"].get("file", ""), "state": "printing" if active else "standby",
"current_layer": 23 if active else 0, "total_layers": 55, "remaining": 2940}}
@app.get("/api/canvas")
def canvas():
return {"telemetry": {"result": {"canvas_info": {"canvas_list": [{"tray_list": [
{"tray_id": s["slot"], "status": s["printer"]["status"]} for s in P["slots"][:4]]}]}}}}
@app.post("/api/spools/enable")
async def enable(request: Request):
body = await request.body()
if body not in (b"on", b"off"):
raise HTTPException(400, "Invalid switch")
with LOCK:
P["enabled"] = body == b"on"
STATE["requests"].append(f"POST enable {body.decode()}")
return {"ok": True, "enabled": P["enabled"]}
@app.post("/api/spools/save")
async def save(request: Request):
f = form(await request.body())
with LOCK:
STATE["requests"].append("POST save " + json.dumps(f, ensure_ascii=False))
if f.get("id"):
sp = next((s for s in P["spools"] if s["id"] == f["id"]), None)
if not sp:
return JSONResponse({"ok": False, "error": "Unknown spool"}, 404)
if "remaining" in f:
return JSONResponse({"ok": False, "error": "Correct the remaining weight with a weigh-in"}, 400)
for k in ("name", "brand", "material", "color", "note"):
if k in f:
sp[k] = f[k]
for k in ("diameter", "density", "net", "tare", "low", "price"):
if k in f:
sp[k] = float(f[k])
if "archived" in f:
sp["archived"] = f["archived"] == "1"
return {"ok": True, "id": sp["id"]}
if not f.get("material") or not f.get("color"):
return JSONResponse({"ok": False, "error": "A spool needs a material and a colour"}, 400)
sp = {"id": next(P["ids"]), "name": f.get("name", ""), "brand": f.get("brand", ""), "material": f["material"],
"color": f["color"], "diameter": float(f.get("diameter", 1.75)), "density": float(f.get("density", 1.24)),
"net": float(f.get("net", 1000)), "remaining": float(f.get("remaining", f.get("net", 1000))),
"tare": float(f.get("tare", 0)), "low": 0.0, "price": float(f.get("price", 0)),
"note": f.get("note", ""), "created": 0, "used": 0, "archived": False}
P["spools"].append(sp)
return JSONResponse({"ok": True, "id": sp["id"]}, 201)
@app.post("/api/spools/adjust")
async def adjust(request: Request):
f = form(await request.body())
with LOCK:
STATE["requests"].append("POST adjust " + json.dumps(f))
sp = next((s for s in P["spools"] if s["id"] == f.get("id")), None)
if not sp:
return JSONResponse({"ok": False, "error": "Unknown spool"}, 404)
value = float(f["remaining"])
P["log"].insert(0, {"time": int(time.time()), "spool": sp["id"], "slot": -1, "kind": "correction",
"grams": value - sp["remaining"], "mm": 0.0, "job": "", "result": "set"})
sp["remaining"] = value
return {"ok": True, "remaining": value}
@app.post("/api/spools/delete")
async def delete(request: Request):
f = form(await request.body())
with LOCK:
STATE["requests"].append("POST delete " + json.dumps(f))
if not any(s["id"] == f.get("id") for s in P["spools"]):
return JSONResponse({"ok": False, "error": "Unknown spool"}, 404)
P["spools"][:] = [s for s in P["spools"] if s["id"] != f["id"]]
for s in P["slots"]:
if s["spool"] == f["id"]:
s["spool"] = ""
return {"ok": True}
@app.post("/api/spools/assign")
async def assign(request: Request):
f = form(await request.body())
with LOCK:
STATE["requests"].append("POST assign " + json.dumps(f))
slot = int(f["slot"])
for s in P["slots"]:
if f["spool"] and s["spool"] == f["spool"]:
s["spool"] = ""
P["slots"][slot]["spool"] = f["spool"]
P["slots"][slot]["question"] = ""
return {"ok": True}
return app
# ---------- Spoolman ----------
def make_spoolman() -> FastAPI:
app = FastAPI()
def get(sid: int):
if sid not in STATE["spoolman"]:
raise HTTPException(404, "not found")
return STATE["spoolman"][sid]
@app.get("/api/v1/spool")
def spools(allow_archived: bool = False):
return [s for s in STATE["spoolman"].values() if allow_archived or not s["archived"]]
@app.get("/api/v1/spool/{sid}")
def spool(sid: int):
return get(sid)
@app.patch("/api/v1/spool/{sid}")
async def patch(sid: int, request: Request):
body = await request.json()
with LOCK:
sp = get(sid)
for key, value in (body.get("extra") or {}).items():
if value is not None and key not in STATE["fields"]:
raise HTTPException(400, f"Unknown extra field {key}")
sp.update({k: v for k, v in body.items() if k in ("location", "lot_nr", "archived")})
if "used_weight" in body:
sp["used_weight"] = float(body["used_weight"])
sp["remaining_weight"] = (sp.get("initial_weight") or 1000.0) - sp["used_weight"]
for key, value in (body.get("extra") or {}).items():
if value is None:
sp["extra"].pop(key, None)
else:
sp["extra"][key] = value
STATE["requests"].append(f"PATCH spoolman {sid} {json.dumps(body, ensure_ascii=False)}")
return sp
@app.get("/api/v1/vendor")
def vendors():
found = {s["filament"]["vendor"]["id"]: s["filament"]["vendor"] for s in STATE["spoolman"].values()}
found.update({v["id"]: v for v in STATE["vendors"]})
return list(found.values())
@app.get("/api/v1/filament")
def filaments():
found = {s["filament"]["id"]: s["filament"] for s in STATE["spoolman"].values()}
found.update({f["id"]: f for f in STATE["filaments"]})
return list(found.values())
@app.post("/api/v1/vendor")
async def add_vendor(request: Request):
body = await request.json()
with LOCK:
v = {"id": max([x["id"] for x in vendors()], default=0) + 1, **body}
STATE["vendors"].append(v)
STATE["requests"].append(f"POST vendor {json.dumps(body, ensure_ascii=False)}")
return v
@app.post("/api/v1/filament")
async def add_filament(request: Request):
body = await request.json()
with LOCK:
vendor = next(v for v in vendors() if v["id"] == body["vendor_id"])
f = {"id": max([x["id"] for x in filaments()], default=0) + 1, "vendor": vendor, **body}
STATE["filaments"].append(f)
STATE["requests"].append(f"POST filament {json.dumps(body, ensure_ascii=False)}")
return f
@app.post("/api/v1/spool")
async def add_spool(request: Request):
body = await request.json()
with LOCK:
fil = next(f for f in filaments() if f["id"] == body["filament_id"])
sid = max(STATE["spoolman"], default=0) + 1
weight = body.get("initial_weight") or fil.get("weight") or 1000.0
STATE["spoolman"][sid] = {"id": sid, "lot_nr": None, "remaining_weight": weight, "used_weight": 0.0,
"initial_weight": weight, "spool_weight": body.get("spool_weight"),
"location": body.get("location", ""), "archived": False, "price": body.get("price"),
"extra": {}, "filament": fil}
STATE["requests"].append(f"POST spool {json.dumps(body, ensure_ascii=False)}")
return STATE["spoolman"][sid]
@app.get("/api/v1/field/spool")
def fields():
return [{"key": k, "entity_type": "spool", **v} for k, v in STATE["fields"].items()]
@app.post("/api/v1/field/spool/{key}")
async def add_field(key: str, request: Request):
body = await request.json()
with LOCK:
STATE["fields"][key] = body
STATE["requests"].append(f"POST field {key} {json.dumps(body, ensure_ascii=False)}")
return fields()
@app.put("/api/v1/spool/{sid}/use")
async def use(sid: int, request: Request):
body = await request.json()
with LOCK:
sp = get(sid)
sp["used_weight"] += body["use_weight"]
sp["remaining_weight"] -= body["use_weight"]
STATE["requests"].append(f"PUT spoolman {sid} use {body['use_weight']}")
return sp
return app
# ---------- control ----------
def make_control() -> FastAPI:
app = FastAPI()
@app.get("/state")
def state():
return {**STATE, "printers": [{k: v for k, v in p.items() if k != "ids"} for p in PRINTERS]}
@app.post("/insert")
def insert(slot: int, spool: int, color: str = "0x000000", printer: int = 0):
P = PRINTERS[printer]
with LOCK:
P["slots"][slot]["printer"] = {"status": 1}
P["slots"][slot]["question"] = "inserted"
P["slots"][slot]["spool"] = ""
P["record"][f"channels{slot}"] = channel(10000 + spool, color=color)
return {"ok": True}
@app.post("/remove")
def remove(slot: int, printer: int = 0):
P = PRINTERS[printer]
with LOCK:
P["slots"][slot]["printer"] = {"status": 0}
if P["slots"][slot]["spool"]:
P["slots"][slot]["last"] = P["slots"][slot]["spool"]
P["slots"][slot]["spool"] = ""
P["slots"][slot]["question"] = ""
return {"ok": True}
@app.post("/putback")
def putback(slot: int, printer: int = 0):
P = PRINTERS[printer]
with LOCK:
P["slots"][slot]["printer"] = {"status": 1}
P["slots"][slot]["spool"] = ""
P["slots"][slot]["question"] = "inserted"
return {"ok": True}
@app.post("/takeout")
def takeout(slot: int, printer: int = 0):
P = PRINTERS[printer]
with LOCK:
P["slots"][slot]["last"] = P["slots"][slot]["spool"]
P["slots"][slot]["spool"] = ""
P["slots"][slot]["question"] = ""
return {"ok": True}
def log(P, kind, spool, slot, grams, mm, job="", result=""):
P["log"].insert(0, {"time": int(time.time()), "spool": spool, "slot": slot, "kind": kind, "grams": grams,
"mm": mm, "job": job, "result": result})
del P["log"][150:]
@app.post("/print")
def print_(slot: int, mm: float, job: str = "test.gcode", result: str = "complete", printer: int = 0):
P = PRINTERS[printer]
with LOCK:
sid = P["slots"][slot]["spool"]
sp = next((s for s in P["spools"] if s["id"] == sid), None)
d, rho = (sp["diameter"], sp["density"]) if sp else (1.75, 1.24)
grams = mm * 3.141592653589793 * (d / 2) ** 2 * rho / 1000
if sp:
sp["remaining"] -= grams
log(P, "print", sid, slot, -round(grams, 3), mm, job, result)
return {"ok": True, "grams": grams}
@app.post("/weigh")
def weigh(spool: str, remaining: float, printer: int = 0):
P = PRINTERS[printer]
with LOCK:
sp = next(s for s in P["spools"] if s["id"] == spool)
log(P, "correction", spool, -1, remaining - sp["remaining"], 0, "", "weighed")
sp["remaining"] = remaining
return {"ok": True}
@app.post("/touch")
def touch(slot: int, printer: int = 0):
P = PRINTERS[printer]
with LOCK:
P["record"][f"channels{slot}"]["weight"] = "1000"
P["slots"][slot]["spool"] = ""
P["slots"][slot]["question"] = "changed"
return {"ok": True}
@app.post("/job")
def job(active: int, printer: int = 0):
P = PRINTERS[printer]
with LOCK:
P["job"]["active"] = bool(active)
return {"ok": True}
@app.post("/consume")
def consume(slot: int, grams: float, printer: int = 0):
P = PRINTERS[printer]
with LOCK:
sid = P["slots"][slot]["spool"]
sp = next(s for s in P["spools"] if s["id"] == sid)
sp["remaining"] -= grams
return {"ok": True, "remaining": sp["remaining"]}
@app.post("/authorize")
async def authorize(request: Request):
AUTHORIZED.add((await request.body()).decode().split()[1])
return {"ok": True}
return app
# ---------- SSH ----------
AUTHORIZED: set[str] = set()
class SSHServer(paramiko.ServerInterface):
def __init__(self, P: dict):
self.P = P
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):
return paramiko.AUTH_SUCCESSFUL if username == "root" and key.get_base64() in AUTHORIZED else paramiko.AUTH_FAILED
def check_channel_exec_request(self, channel_, command):
def reply(): # forced command, whatever was sent
with LOCK:
data = json.dumps(self.P["record"]).encode()
channel_.sendall(data)
channel_.send_exit_status(0)
channel_.close()
threading.Timer(0.05, reply).start() # after the exec request is acknowledged
return True
def ssh_server(port: int, P: dict):
host_key = paramiko.Ed25519Key.generate() if hasattr(paramiko.Ed25519Key, "generate") else None
if host_key is None:
from io import StringIO
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
pem = Ed25519PrivateKey.generate().private_bytes(
serialization.Encoding.PEM, serialization.PrivateFormat.OpenSSH, serialization.NoEncryption()).decode()
host_key = paramiko.Ed25519Key.from_private_key(StringIO(pem))
sock = socket.socket()
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("0.0.0.0", port))
sock.listen(10)
print(f"mock SSH on {port}, host key {host_key.fingerprint}", flush=True)
while True:
conn, _ = sock.accept()
t = paramiko.Transport(conn)
t.add_server_key(host_key)
try:
t.start_server(server=SSHServer(P))
except Exception:
pass
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="127.0.0.1", help="host part the CC2 Control guard expects")
ap.add_argument("--printers", type=int, default=1)
args = ap.parse_args()
seed_spoolman()
servers = [
uvicorn.Server(uvicorn.Config(make_spoolman(), host="0.0.0.0", port=7912, log_level="warning")),
uvicorn.Server(uvicorn.Config(make_control(), host="0.0.0.0", port=8099, log_level="warning")),
]
for i in range(args.printers):
P = printer_state()
PRINTERS.append(P)
threading.Thread(target=ssh_server, args=(2222 + i, P), daemon=True).start()
servers.append(uvicorn.Server(uvicorn.Config(make_cc2(f"{args.host}:{8081 + i}", P), host="0.0.0.0",
port=8081 + i, log_level="warning")))
threads = [threading.Thread(target=s.run, daemon=True) for s in servers[1:]]
for t in threads:
t.start()
servers[0].run()
if __name__ == "__main__":
main()