Files
cc2-spoolman-bridge/app/cc2control.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

157 lines
5.1 KiB
Python

"""CC2 Control HTTP client (spool management only).
Request rules from CC2 Control's HTTP guard: the Host header must be exactly the
printer's host:port, no Origin header, and writes need `X-CC2-Request: 1` with a
plain-text body of `key=value` lines.
"""
from __future__ import annotations
import re
from urllib.parse import urlsplit
import httpx
TEXT_LIMITS = {"name": 96, "brand": 96, "note": 96, "material": 32}
_FORBIDDEN = re.compile(r'["\\\x00-\x1f\x7f]')
NOTE_PREFIX = "spoolman:"
class CC2Error(RuntimeError):
pass
def clean_text(value, limit: int | None = None) -> str:
"""UTF-8 without quotes, backslashes and control characters, cut to `limit` bytes."""
text = _FORBIDDEN.sub(" ", str(value)).strip()
text = re.sub(r" {2,}", " ", text)
if limit is not None:
raw = text.encode("utf-8")[:limit]
text = raw.decode("utf-8", "ignore").rstrip()
return text
def fmt_number(value) -> str:
v = float(value)
if v == int(v):
return str(int(v))
return f"{v:.3f}".rstrip("0").rstrip(".")
def encode_body(fields: dict) -> str:
lines = []
for key, value in fields.items():
if value is None:
continue
if not re.fullmatch(r"[a-z_]+", key):
raise ValueError(f"invalid key {key!r}")
if isinstance(value, bool):
value = "1" if value else "0"
elif isinstance(value, (int, float)):
value = fmt_number(value)
else:
value = clean_text(value, TEXT_LIMITS.get(key))
lines.append(f"{key}={value}")
return "\n".join(lines) + "\n"
def spoolman_id_of(cc2_spool: dict) -> int | None:
note = str(cc2_spool.get("note") or "")
m = re.search(r"\bspoolman:(\d+)\b", note)
return int(m.group(1)) if m else None
def slot_spool_id(slot: dict) -> str | None:
"""CC2 Control spool ids are strings; an empty string means the slot holds no spool."""
s = slot.get("spool")
if isinstance(s, dict):
s = s.get("id")
s = "" if s is None else str(s)
return s or None
class CC2Control:
def __init__(self, base_url: str, timeout: float = 8.0, transport: httpx.BaseTransport | None = None):
parts = urlsplit(base_url)
self.host_header = parts.netloc
self.client = httpx.Client(
base_url=base_url.rstrip("/"), timeout=timeout, transport=transport,
headers={"Host": self.host_header, "Accept": "application/json"},
)
# httpx never adds Origin by itself; make sure nothing else does either.
self.client.headers.pop("Origin", None)
def close(self):
self.client.close()
def _get(self, path: str) -> dict:
try:
r = self.client.get(path)
except httpx.HTTPError as e:
raise CC2Error(f"CC2 Control nicht erreichbar ({type(e).__name__})") from None
if r.status_code != 200:
raise CC2Error(f"CC2 Control {path}: HTTP {r.status_code}")
try:
return r.json()
except ValueError:
raise CC2Error(f"CC2 Control {path}: keine JSON-Antwort") from None
def _post(self, path: str, fields: dict | str) -> dict:
# /api/spools/enable takes the bare word "on"/"off" as its body.
body = fields if isinstance(fields, str) else encode_body(fields)
try:
r = self.client.post(
path, content=body.encode("utf-8"),
headers={"X-CC2-Request": "1", "Content-Type": "text/plain; charset=utf-8"},
)
except httpx.HTTPError as e:
raise CC2Error(f"CC2 Control nicht erreichbar ({type(e).__name__})") from None
if r.status_code not in (200, 201):
detail = ""
try:
detail = str(r.json().get("error") or "")[:200]
except ValueError:
pass
raise CC2Error(f"CC2 Control {path}: HTTP {r.status_code} {detail}".strip())
try:
return r.json()
except ValueError:
return {}
def canvas(self) -> dict:
return self._get("/api/canvas")
def printer(self) -> dict:
return self._get("/api/printer")
def spools(self) -> dict:
return self._get("/api/spools")
def save_spool(self, fields: dict) -> dict:
return self._post("/api/spools/save", fields)
def assign(self, slot: int, spool_id: int | None) -> dict:
return self._post("/api/spools/assign", {"slot": slot, "spool": "" if spool_id is None else spool_id})
def enable(self, on: bool) -> dict:
return self._post("/api/spools/enable", "on" if on else "off")
def tray_statuses(canvas: dict) -> dict[int, int]:
"""Slot -> tray status (0 = no filament tip at the feeder, 1 = loaded, 2 = feeding)."""
try:
trays = canvas["telemetry"]["result"]["canvas_info"]["canvas_list"][0]["tray_list"]
except (KeyError, IndexError, TypeError):
return {}
out = {}
for i, tray in enumerate(trays or []):
if not isinstance(tray, dict):
continue
slot = tray.get("tray_id", i)
try:
out[int(slot)] = int(tray.get("status"))
except (TypeError, ValueError):
continue
return out