- 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
314 lines
12 KiB
Python
314 lines
12 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-header mock:8081
|
|
|
|
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).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import itertools
|
|
import json
|
|
import socket
|
|
import sys
|
|
import threading
|
|
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
|
|
|
|
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))
|
|
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,
|
|
"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}},
|
|
}
|
|
|
|
|
|
# ---------- CC2 Control ----------
|
|
|
|
def make_cc2(host_header: str) -> 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": STATE["enabled"], "slots": STATE["slots"], "spools": STATE["spools"],
|
|
"job": STATE["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]]}]}}}}
|
|
|
|
@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:
|
|
STATE["enabled"] = body == b"on"
|
|
STATE["requests"].append(f"POST enable {body.decode()}")
|
|
return {"ok": True, "enabled": STATE["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 STATE["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]
|
|
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(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)
|
|
return JSONResponse({"ok": True, "id": sp["id"]}, 201)
|
|
|
|
@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 STATE["slots"]:
|
|
if f["spool"] and s["spool"] == f["spool"]:
|
|
s["spool"] = ""
|
|
STATE["slots"][slot]["spool"] = f["spool"]
|
|
STATE["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)
|
|
sp.update({k: v for k, v in body.items() if k in ("location",)})
|
|
STATE["requests"].append(f"PATCH spoolman {sid} {json.dumps(body, ensure_ascii=False)}")
|
|
return sp
|
|
|
|
@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
|
|
|
|
@app.post("/insert")
|
|
def insert(slot: int, spool: int, color: str = "0x000000"):
|
|
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)
|
|
return {"ok": True}
|
|
|
|
@app.post("/remove")
|
|
def remove(slot: int):
|
|
with LOCK:
|
|
STATE["slots"][slot]["printer"] = {"status": 0}
|
|
STATE["record"][f"channels{slot}"] = channel("")
|
|
return {"ok": True}
|
|
|
|
@app.post("/touch")
|
|
def touch(slot: int):
|
|
with LOCK:
|
|
STATE["record"][f"channels{slot}"]["weight"] = "1000"
|
|
STATE["slots"][slot]["spool"] = ""
|
|
STATE["slots"][slot]["question"] = "changed"
|
|
return {"ok": True}
|
|
|
|
@app.post("/job")
|
|
def job(active: int):
|
|
with LOCK:
|
|
STATE["job"]["active"] = bool(active)
|
|
return {"ok": True}
|
|
|
|
@app.post("/consume")
|
|
def consume(slot: int, grams: float):
|
|
with LOCK:
|
|
sid = STATE["slots"][slot]["spool"]
|
|
sp = next(s for s in STATE["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 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(STATE["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):
|
|
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())
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--host-header", default="127.0.0.1:8081")
|
|
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")),
|
|
]
|
|
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()
|