"""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