Multiple printers, printer/slot in Spoolman, kept in sync
- One worker per printer (name, IP, CC2 Control URL, own pinned host key); a spool sits in at most one slot of one printer, moves between printers - Spoolman: location "<printer> Slot N" plus extra fields "drucker"/"slot" (created by the bridge), cleared and "Lager" on removal - Full comparison every minute: the printer wins over manual edits of loaded spools; stale printer locations go to storage; own locations untouched - Automatic migration of v1 settings, state and host key - Status hint when a tag is still in a tray but unmapped (e.g. after "Take out" in CC2 Control) - SSH test: connection drops during exec give a clean error, not a 500 - Mock environment with several printers; tests for all of the above Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014GcEk5tkfFSYD6NTF66NkC
This commit is contained in:
1 parent
bf32667f77
commit
a9041eb538
15 files changed
+1400
-684
No files matched your search
+89
-51
@@ -1,10 +1,10 @@
|
||||
"""Local stand-in for the printer (SSH), CC2 Control and Spoolman, for manual end-to-end tests.
|
||||
|
||||
python tools/mock_env.py --host-header mock:8081
|
||||
python tools/mock_env.py --host mock --printers 2
|
||||
|
||||
Ports: 2222 SSH (any key listed in /mock/authorized), 8081 CC2 Control, 7912 Spoolman, 8099 control.
|
||||
Control endpoints (port 8099): POST /insert?slot=&spool=, /remove?slot=, /consume?slot=&grams=,
|
||||
/job?active=0|1, /touch?slot= (touchscreen edit: weight falls back to 1000).
|
||||
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=, /remove?slot=,
|
||||
/consume?slot=&grams=, /job?active=0|1, /touch?slot= (touchscreen edit: weight falls back to 1000).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -25,17 +25,21 @@ from fastapi.responses import JSONResponse
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from tests.fakes import channel, record # noqa: E402
|
||||
|
||||
STATE = {
|
||||
"record": record(),
|
||||
"enabled": True,
|
||||
"slots": [{"slot": i, "spool": "", "last": "", "question": "", "printer": {"status": 0}} for i in range(5)],
|
||||
"spools": [],
|
||||
"job": {"active": False, "file": "", "slots": []},
|
||||
"spoolman": {},
|
||||
"requests": [],
|
||||
}
|
||||
STATE["slots"][4]["printer"] = None
|
||||
IDS = (f"m{i:04d}" for i in itertools.count(1))
|
||||
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": []},
|
||||
"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": []}
|
||||
LOCK = threading.Lock()
|
||||
|
||||
|
||||
@@ -46,6 +50,7 @@ def seed_spoolman():
|
||||
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, "name": vendor}},
|
||||
@@ -54,7 +59,7 @@ def seed_spoolman():
|
||||
|
||||
# ---------- CC2 Control ----------
|
||||
|
||||
def make_cc2(host_header: str) -> FastAPI:
|
||||
def make_cc2(host_header: str, P: dict) -> FastAPI:
|
||||
app = FastAPI()
|
||||
|
||||
@app.middleware("http")
|
||||
@@ -85,13 +90,13 @@ def make_cc2(host_header: str) -> FastAPI:
|
||||
def spools():
|
||||
with LOCK:
|
||||
STATE["requests"].append("GET /api/spools")
|
||||
return {"available": True, "enabled": STATE["enabled"], "slots": STATE["slots"], "spools": STATE["spools"],
|
||||
"job": STATE["job"], "log": []}
|
||||
return {"available": True, "enabled": P["enabled"], "slots": P["slots"], "spools": P["spools"],
|
||||
"job": P["job"], "log": []}
|
||||
|
||||
@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 STATE["slots"][:4]]}]}}}}
|
||||
{"tray_id": s["slot"], "status": s["printer"]["status"]} for s in P["slots"][:4]]}]}}}}
|
||||
|
||||
@app.post("/api/spools/enable")
|
||||
async def enable(request: Request):
|
||||
@@ -99,9 +104,9 @@ def make_cc2(host_header: str) -> FastAPI:
|
||||
if body not in (b"on", b"off"):
|
||||
raise HTTPException(400, "Invalid switch")
|
||||
with LOCK:
|
||||
STATE["enabled"] = body == b"on"
|
||||
P["enabled"] = body == b"on"
|
||||
STATE["requests"].append(f"POST enable {body.decode()}")
|
||||
return {"ok": True, "enabled": STATE["enabled"]}
|
||||
return {"ok": True, "enabled": P["enabled"]}
|
||||
|
||||
@app.post("/api/spools/save")
|
||||
async def save(request: Request):
|
||||
@@ -109,7 +114,7 @@ def make_cc2(host_header: str) -> FastAPI:
|
||||
with LOCK:
|
||||
STATE["requests"].append("POST save " + json.dumps(f, ensure_ascii=False))
|
||||
if f.get("id"):
|
||||
sp = next((s for s in STATE["spools"] if s["id"] == f["id"]), None)
|
||||
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:
|
||||
@@ -122,12 +127,12 @@ def make_cc2(host_header: str) -> FastAPI:
|
||||
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(IDS), "name": f.get("name", ""), "brand": f.get("brand", ""), "material": f["material"],
|
||||
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}
|
||||
STATE["spools"].append(sp)
|
||||
P["spools"].append(sp)
|
||||
return JSONResponse({"ok": True, "id": sp["id"]}, 201)
|
||||
|
||||
@app.post("/api/spools/assign")
|
||||
@@ -136,11 +141,11 @@ def make_cc2(host_header: str) -> FastAPI:
|
||||
with LOCK:
|
||||
STATE["requests"].append("POST assign " + json.dumps(f))
|
||||
slot = int(f["slot"])
|
||||
for s in STATE["slots"]:
|
||||
for s in P["slots"]:
|
||||
if f["spool"] and s["spool"] == f["spool"]:
|
||||
s["spool"] = ""
|
||||
STATE["slots"][slot]["spool"] = f["spool"]
|
||||
STATE["slots"][slot]["question"] = ""
|
||||
P["slots"][slot]["spool"] = f["spool"]
|
||||
P["slots"][slot]["question"] = ""
|
||||
return {"ok": True}
|
||||
|
||||
return app
|
||||
@@ -169,10 +174,30 @@ def make_spoolman() -> FastAPI:
|
||||
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",)})
|
||||
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/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()
|
||||
@@ -193,43 +218,48 @@ def make_control() -> FastAPI:
|
||||
|
||||
@app.get("/state")
|
||||
def state():
|
||||
return 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"):
|
||||
def insert(slot: int, spool: int, color: str = "0x000000", printer: int = 0):
|
||||
P = PRINTERS[printer]
|
||||
with LOCK:
|
||||
STATE["slots"][slot]["printer"] = {"status": 1}
|
||||
STATE["slots"][slot]["question"] = "inserted"
|
||||
STATE["slots"][slot]["spool"] = ""
|
||||
STATE["record"][f"channels{slot}"] = channel(10000 + spool, color=color)
|
||||
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):
|
||||
def remove(slot: int, printer: int = 0):
|
||||
P = PRINTERS[printer]
|
||||
with LOCK:
|
||||
STATE["slots"][slot]["printer"] = {"status": 0}
|
||||
STATE["record"][f"channels{slot}"] = channel("")
|
||||
P["slots"][slot]["printer"] = {"status": 0}
|
||||
P["record"][f"channels{slot}"] = channel("")
|
||||
return {"ok": True}
|
||||
|
||||
@app.post("/touch")
|
||||
def touch(slot: int):
|
||||
def touch(slot: int, printer: int = 0):
|
||||
P = PRINTERS[printer]
|
||||
with LOCK:
|
||||
STATE["record"][f"channels{slot}"]["weight"] = "1000"
|
||||
STATE["slots"][slot]["spool"] = ""
|
||||
STATE["slots"][slot]["question"] = "changed"
|
||||
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):
|
||||
def job(active: int, printer: int = 0):
|
||||
P = PRINTERS[printer]
|
||||
with LOCK:
|
||||
STATE["job"]["active"] = bool(active)
|
||||
P["job"]["active"] = bool(active)
|
||||
return {"ok": True}
|
||||
|
||||
@app.post("/consume")
|
||||
def consume(slot: int, grams: float):
|
||||
def consume(slot: int, grams: float, printer: int = 0):
|
||||
P = PRINTERS[printer]
|
||||
with LOCK:
|
||||
sid = STATE["slots"][slot]["spool"]
|
||||
sp = next(s for s in STATE["spools"] if s["id"] == sid)
|
||||
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"]}
|
||||
|
||||
@@ -247,6 +277,9 @@ 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
|
||||
|
||||
@@ -259,7 +292,7 @@ class SSHServer(paramiko.ServerInterface):
|
||||
def check_channel_exec_request(self, channel_, command):
|
||||
def reply(): # forced command, whatever was sent
|
||||
with LOCK:
|
||||
data = json.dumps(STATE["record"]).encode()
|
||||
data = json.dumps(self.P["record"]).encode()
|
||||
channel_.sendall(data)
|
||||
channel_.send_exit_status(0)
|
||||
channel_.close()
|
||||
@@ -267,7 +300,7 @@ class SSHServer(paramiko.ServerInterface):
|
||||
return True
|
||||
|
||||
|
||||
def ssh_server(port: int):
|
||||
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
|
||||
@@ -287,22 +320,27 @@ def ssh_server(port: int):
|
||||
t = paramiko.Transport(conn)
|
||||
t.add_server_key(host_key)
|
||||
try:
|
||||
t.start_server(server=SSHServer())
|
||||
t.start_server(server=SSHServer(P))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--host-header", default="127.0.0.1:8081")
|
||||
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()
|
||||
threading.Thread(target=ssh_server, args=(2222,), daemon=True).start()
|
||||
servers = [
|
||||
uvicorn.Server(uvicorn.Config(make_cc2(args.host_header), host="0.0.0.0", port=8081, log_level="warning")),
|
||||
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()
|
||||
|
||||
Reference in new issue
Block a user