- 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
157 lines
5.1 KiB
Python
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
|