import logging import math import os import re import time import httpx logger = logging.getLogger("pops") _cache: dict[str, tuple[float, list]] = {} _TTL = 300 # seconds def _base() -> str: return os.environ.get("FILAMENTDB_URL", "https://web.filamentdb.orb.local").rstrip("/") def _get(path: str, params: dict | None = None) -> list | dict: r = httpx.get(f"{_base()}{path}", params=params, timeout=5, verify=False) r.raise_for_status() return r.json() def _cached(key: str, fetch): now = time.monotonic() if key in _cache and now - _cache[key][0] < _TTL: return _cache[key][1] try: result = fetch() _cache[key] = (now, result) return result except Exception: logger.warning("FilamentDB unreachable — using cached/fallback data") return _cache.get(key, (0, []))[1] def get_stock() -> list[dict]: """All filament types with stock counts from /reports/stock.""" return _cached("stock", lambda: _get("/reports/stock")) def available_materials() -> list[str]: """Unique materials that have at least one roll in stock.""" rows = get_stock() seen, out = set(), [] for r in rows: m = r.get("material", "") if m and r.get("rolls_in_stock", 0) > 0 and m not in seen: seen.add(m) out.append(m) return sorted(out) def spool_counts() -> dict[tuple[str, str], int]: """Returns {(material, color_name): available_rolls} from cached stock. 'Available' = rolls_in_stock + rolls_in_use (anything not in the graveyard). Multiple filament types with the same material+color are summed. """ result: dict[tuple[str, str], int] = {} for r in get_stock(): key = (r.get("material") or "", r.get("color_name") or "") count = (r.get("rolls_in_stock") or 0) + (r.get("rolls_in_use") or 0) result[key] = result.get(key, 0) + count return result def _parse_ams_html(html: str) -> list[dict]: """ Parse the /ams/ HTML page to extract slot info. Each card yields: slot, material, color, weight_remaining_g, roll_id (numeric). """ slots = [] for card in re.split(r'class="card ams-card"', html)[1:]: slot_m = re.search(r'

Slot (\d+)

', card) matcol_m = re.search( r'.*?
\s*(.*?)
\s*(.*?)\s*

', card, re.DOTALL ) weight_m = re.search(r'~(\d+)g', card) roll_id_m = re.search(r'action="/rolls/(\d+)/prints"', card) if slot_m and matcol_m and weight_m and roll_id_m: slots.append({ "slot": int(slot_m.group(1)), "material": matcol_m.group(1).strip(), "color": matcol_m.group(2).strip(), "weight_remaining_g": int(weight_m.group(1)), "roll_id": int(roll_id_m.group(1)), }) return slots def get_ams_slots() -> list[dict]: """Return currently loaded AMS slots (live, not cached — state changes often).""" try: r = httpx.get(f"{_base()}/ams/", timeout=5, verify=False) r.raise_for_status() return _parse_ams_html(r.text) except Exception: logger.warning("FilamentDB AMS endpoint unreachable") return [] def find_matching_ams_roll(material: str, color: str) -> dict | None: """ Find the best roll in AMS1/slots 1-4 matching material+color. If multiple match, pick the one with least filament remaining (use it up first). """ matches = [ s for s in get_ams_slots() if s["material"].lower() == material.lower() and s["color"].lower() == color.lower() ] return min(matches, key=lambda s: s["weight_remaining_g"]) if matches else None def log_print_on_roll(roll_id: int, weight_g: int, note: str) -> bool: """POST a print log entry to FilamentDB for the given roll.""" try: r = httpx.post( f"{_base()}/rolls/{roll_id}/prints", data={"weight_used_g": weight_g, "note": note, "redirect_to": ""}, timeout=5, verify=False, ) r.raise_for_status() return True except Exception: logger.warning("FilamentDB print log failed for roll %s", roll_id) return False def available_colors(material: str | None = None) -> list[str]: """Unique color names in stock, optionally filtered by material.""" rows = get_stock() seen, out = set(), [] for r in rows: if r.get("rolls_in_stock", 0) <= 0: continue if material and r.get("material") != material: continue c = r.get("color_name") or "" if c and c not in seen: seen.add(c) out.append(c) return sorted(out)