Files
cc2-spoolman-bridge/app/web.py
T
Jonas MenzelandClaude Opus 5.5 a9041eb538
CI / test (push) Successful in 1m6s
CI / image (push) Successful in 43s
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
2026-10-25 03:53:01 +01:00

321 lines
12 KiB
Python

"""Web UI and JSON API."""
from __future__ import annotations
import base64
import hmac
import logging
import os
import threading
from contextlib import asynccontextmanager
from pathlib import Path
import paramiko
import segno
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, Response
from fastapi.templating import Jinja2Templates
from . import materials, tagcodec
from .bridge import Bridge
from .sshsource import CanvasRecordReader, HostKeyMismatch, KeyStore, SSHError, fingerprint
from .spoolman import SpoolmanError, summary
from .storage import SettingsStore
log = logging.getLogger("web")
HERE = Path(__file__).parent
VERSION = os.environ.get("APP_VERSION", "dev")
def create_app(data_dir: Path | None = None, *, bridge: Bridge | None = None, start_loop: bool = True,
env: dict | None = None) -> FastAPI:
env = os.environ if env is None else env
data_dir = Path(data_dir or env.get("DATA_DIR", "/data"))
data_dir.mkdir(parents=True, exist_ok=True)
settings = bridge.settings_store if bridge else SettingsStore(data_dir, env)
keys = bridge.keys if bridge else KeyStore(data_dir)
bridge = bridge or Bridge(data_dir, settings, keys)
stop = threading.Event()
tested_host_keys: dict[str, dict] = {} # printer id -> host key from its last connection test
@asynccontextmanager
async def lifespan(app):
thread = None
if start_loop:
thread = threading.Thread(target=bridge.run, args=(stop,), name="bridge", daemon=True)
thread.start()
yield
stop.set()
bridge.wake.set()
if thread:
thread.join(timeout=15)
app = FastAPI(title="CC2 Spoolman Bridge", lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None)
app.state.bridge = bridge
templates = Jinja2Templates(directory=str(HERE / "templates"))
user, password = env.get("BRIDGE_USER", ""), env.get("BRIDGE_PASSWORD", "")
@app.middleware("http")
async def guard(request: Request, call_next):
if user and password and request.url.path != "/healthz":
ok = False
auth = request.headers.get("authorization", "")
if auth.lower().startswith("basic "):
try:
u, _, p = base64.b64decode(auth[6:]).decode("utf-8").partition(":")
ok = hmac.compare_digest(u, user) & hmac.compare_digest(p, password)
except (ValueError, UnicodeDecodeError):
ok = False
if not ok:
return Response(status_code=401, headers={"WWW-Authenticate": 'Basic realm="CC2 Bridge"'})
# Writes only from our own pages: a custom header cannot be sent cross-site without CORS.
if request.method not in ("GET", "HEAD") and request.headers.get("x-bridge-request") != "1":
return JSONResponse({"error": "Fehlender X-Bridge-Request-Header"}, status_code=403)
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "no-referrer"
response.headers["X-Frame-Options"] = "DENY"
return response
def page(request: Request, name: str, **ctx):
return templates.TemplateResponse(request, name, {"version": VERSION, "active": name, **ctx})
# ---------- pages ----------
@app.get("/", response_class=HTMLResponse)
def index(request: Request):
return page(request, "status.html")
@app.get("/tags", response_class=HTMLResponse)
def tags(request: Request):
return page(request, "tags.html")
@app.get("/settings", response_class=HTMLResponse)
def settings_page(request: Request):
return page(request, "settings.html")
@app.get("/healthz")
def healthz():
return {"ok": True, "version": VERSION, "last_poll": bridge.snapshot.get("last_poll")}
# ---------- status ----------
@app.get("/api/status")
def status():
return {**bridge.snapshot, "dry_run": settings.get().dry_run, "log": bridge.log.recent(200)}
def printer_or_404(pid: str):
p = settings.get().printer(pid)
if p is None:
raise HTTPException(404, "Unbekannter Drucker")
return p
def close_readers():
for w in list(bridge.workers.values()):
w.reader.close()
@app.post("/api/printers/{pid}/cc2/enable")
async def cc2_enable(pid: str, request: Request):
printer_or_404(pid)
body = await _json(request)
if body.get("confirm") is not True:
raise HTTPException(400, "Bestätigung fehlt")
bridge.commands.put(("enable", pid, None))
bridge.wake.set()
return {"ok": True, "dry_run": settings.get().dry_run}
@app.post("/api/printers/{pid}/slots/{slot}/{action}")
def slot_action(pid: str, slot: int, action: str):
printer_or_404(pid)
if slot not in range(4) or action not in ("reassign", "release"):
raise HTTPException(404)
bridge.commands.put((action, pid, slot))
bridge.wake.set()
return {"ok": True}
# ---------- settings ----------
@app.get("/api/settings")
def get_settings():
return settings.get().to_dict()
@app.post("/api/settings")
async def post_settings(request: Request):
body = await _json(request)
before = {p.id for p in settings.get().printers}
allowed = set(settings.get().to_dict())
try:
s = settings.save({k: v for k, v in body.items() if k in allowed})
except ValueError as e:
raise HTTPException(400, str(e)) from None
for pid in before - {p.id for p in s.printers}:
keys.forget_host_key(pid)
bridge.log.add("info", "Einstellungen gespeichert" + (" (Dry-Run aktiv)" if s.dry_run else " (Schreiben AKTIV)"))
bridge.wake.set()
return s.to_dict()
# ---------- SSH ----------
@app.get("/api/ssh")
def ssh_info():
return {"key": keys.key_info(),
"host_keys": {p.id: keys.host_key(p.id) for p in settings.get().printers}}
@app.post("/api/ssh/key")
async def ssh_key(request: Request):
body = await _json(request)
text = body.get("key")
if not isinstance(text, str) or len(text) > 20000:
raise HTTPException(400, "Schlüssel fehlt")
try:
keys.save_key(text)
except SSHError as e:
raise HTTPException(400, str(e)) from None
bridge.log.add("info", "SSH-Schlüssel gespeichert")
close_readers()
return {"key": keys.key_info()}
@app.delete("/api/ssh/key")
def ssh_key_delete():
keys.delete_key()
close_readers()
bridge.log.add("info", "SSH-Schlüssel gelöscht")
return {"key": None}
@app.post("/api/printers/{pid}/ssh/test")
def ssh_test(pid: str):
p = printer_or_404(pid)
reader = CanvasRecordReader(keys, p.id, p.host, p.ssh_port, p.ssh_user)
try:
result = reader.test()
except HostKeyMismatch as e:
return JSONResponse({"ok": False, "error": str(e), "host_key": {"fingerprint": fingerprint(e.key)}},
status_code=200)
except SSHError as e:
return {"ok": False, "error": str(e)}
hk = result["host_key"]
if not result["host_key_pinned"]:
tested_host_keys.pop(pid, None)
tested_host_keys[pid] = hk
slots = tagcodec.parse_record(result["record"], settings.get().id_offset)
return {"ok": True, "host_key": {"fingerprint": hk["fingerprint"], "type": hk["type"]},
"host_key_pinned": result["host_key_pinned"], "slots": slots}
@app.post("/api/printers/{pid}/ssh/hostkey")
async def ssh_hostkey(pid: str, request: Request):
p = printer_or_404(pid)
body = await _json(request)
hk = tested_host_keys.get(pid)
if not hk or hk["fingerprint"] != body.get("fingerprint"):
raise HTTPException(400, "Bitte zuerst „Verbindung testen“ und dann diesen Fingerprint bestätigen")
key = paramiko.PKey.from_type_string(hk["type"], base64.b64decode(hk["key"]))
keys.pin_host_key(pid, p.host, key)
tested_host_keys.pop(pid, None)
bridge.log.add("info", f"{p.name}: Host-Key bestätigt ({hk['fingerprint']})")
bridge.wake.set()
return {"host_key": keys.host_key(pid)}
@app.delete("/api/printers/{pid}/ssh/hostkey")
def ssh_hostkey_delete(pid: str):
p = printer_or_404(pid)
keys.forget_host_key(pid)
close_readers()
bridge.log.add("info", f"{p.name}: gespeicherter Host-Key gelöscht")
return {"host_key": None}
# ---------- tag generator ----------
@app.get("/api/materials")
def get_materials():
return {
"materials": {k: {"temps": v["temps"], "code": f"{v['code']:08X}"} for k, v in materials.MATERIALS.items()},
"subtypes": [{"code": c, "name": n, "hidden": h, "material": materials.material_of_subtype(c)}
for c, n, h in materials.SUBTYPES],
"palette": tagcodec.PALETTE,
}
@app.get("/api/spoolman/spools")
def spoolman_spools(archived: bool = False):
s = settings.get()
client = bridge.spoolman_factory(s)
try:
spools = client.spools(archived=archived)
except SpoolmanError as e:
raise HTTPException(502, str(e)) from None
finally:
client.close()
out = []
for sp in spools:
info = summary(sp)
sug = materials.suggest(info["material"], info["name"])
info["suggest"] = {
"material": sug["material"], "subtype": sug["subtype"],
"temp_min": sug["temps"][0], "temp_max": sug["temps"][1],
"color": tagcodec.nearest_palette_color(info["color"]),
}
info["spoolman_url"] = f"{s.spoolman_url}/spool/show/{info['id']}"
out.append(info)
out.sort(key=lambda x: x["id"])
return {"spools": out, "id_offset": s.id_offset, "max_id": tagcodec.MAX_WEIGHT - s.id_offset}
@app.post("/api/tag")
async def tag(request: Request):
body = await _json(request)
s = settings.get()
try:
data = tagcodec.TagData(
spool_id=int(body["spool_id"]),
color=str(body["color"]),
material=str(body["material"]),
subtype=int(body["subtype"]),
temp_min=int(body["temp_min"]),
temp_max=int(body["temp_max"]),
diameter=float(body.get("diameter") or 1.75),
finish=int(body.get("finish", 0xFF)),
id_offset=s.id_offset,
url=str(body.get("url") or tagcodec.DEFAULT_URL),
)
commands = tagcodec.encode_commands(data)
except (KeyError, TypeError, ValueError) as e:
raise HTTPException(400, str(e) if isinstance(e, tagcodec.TagError) else f"Ungültige Eingabe ({e})") from None
decoded = tagcodec.decode_pages(tagcodec.parse_commands(commands), s.id_offset)
qr = segno.make(commands, error="l").svg_inline(scale=3, border=2, dark="#000", light="#fff")
return {"commands": commands, "decoded": decoded, "qr_svg": qr}
@app.post("/api/tag/decode")
async def tag_decode(request: Request):
body = await _json(request)
try:
pages = tagcodec.parse_commands(str(body.get("commands", "")))
except tagcodec.TagError as e:
raise HTTPException(400, str(e)) from None
return tagcodec.decode_pages(pages, settings.get().id_offset)
return app
async def _json(request: Request) -> dict:
try:
body = await request.json()
except ValueError:
raise HTTPException(400, "Ungültiges JSON") from None
if not isinstance(body, dict):
raise HTTPException(400, "Ungültiges JSON")
return body
def main() -> None:
import uvicorn
logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO"),
format="%(asctime)s %(levelname)s %(name)s: %(message)s")
logging.getLogger("paramiko").setLevel(logging.WARNING)
logging.getLogger("httpx").setLevel(logging.WARNING)
uvicorn.run(create_app(), host="0.0.0.0", port=int(os.environ.get("PORT", "5066")),
log_level="warning", proxy_headers=False)
if __name__ == "__main__":
main()