Files
cc2-spoolman-bridge/app/web.py
T
Jonas MenzelandClaude Opus 5.5 bf32667f77
CI / test (push) Successful in 1m4s
CI / image (push) Successful in 1m1s
CC2 ↔ Spoolman bridge with NFC tag generator
- 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
2026-10-25 03:01:17 +01:00

306 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, object] = {} # fingerprint -> key from the 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)}
@app.post("/api/cc2/enable")
async def cc2_enable(request: Request):
body = await _json(request)
if body.get("confirm") is not True:
raise HTTPException(400, "Bestätigung fehlt")
bridge.commands.put(("enable", None))
bridge.wake.set()
return {"ok": True, "dry_run": settings.get().dry_run}
@app.post("/api/slots/{slot}/{action}")
def slot_action(slot: int, action: str):
if slot not in range(4) or action not in ("reassign", "release"):
raise HTTPException(404)
bridge.commands.put((action, 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)
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
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_key": keys.host_key()}
@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")
if bridge.reader:
bridge.reader.close()
return {"key": keys.key_info()}
@app.delete("/api/ssh/key")
def ssh_key_delete():
keys.delete_key()
if bridge.reader:
bridge.reader.close()
bridge.log.add("info", "SSH-Schlüssel gelöscht")
return {"key": None}
@app.post("/api/ssh/test")
def ssh_test():
s = settings.get()
reader = CanvasRecordReader(keys, s.printer_host, s.ssh_port, s.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.clear()
tested_host_keys[hk["fingerprint"]] = hk
slots = tagcodec.parse_record(result["record"], s.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/ssh/hostkey")
async def ssh_hostkey(request: Request):
body = await _json(request)
hk = tested_host_keys.get(body.get("fingerprint"))
if not hk:
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(settings.get().printer_host, key)
tested_host_keys.clear()
bridge.log.add("info", f"Host-Key des Druckers bestätigt ({hk['fingerprint']})")
bridge.wake.set()
return {"host_key": keys.host_key()}
@app.delete("/api/ssh/hostkey")
def ssh_hostkey_delete():
keys.forget_host_key()
if bridge.reader:
bridge.reader.close()
bridge.log.add("info", "Gespeicherter Host-Key des Druckers 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()