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
This commit is contained in:
commit
bf32667f77
32 files changed
+4216
No files matched your search
@@ -0,0 +1,156 @@
|
||||
"""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
|
||||
Reference in new issue
Block a user