"""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()