- filamentdb.get_ams_slots(): parses /ams/ HTML to extract slot, material,
color, weight_remaining_g, roll_id for each loaded AMS slot
- filamentdb.find_matching_ams_roll(): filters by material+color (case-insensitive),
picks the roll with least filament remaining
- filamentdb.log_print_on_roll(): POSTs to /rolls/{id}/prints
- start_job: after setting job to printing, looks up AMS, computes weight
(math.ceil of estimate), logs against matching roll, writes result to job log
- Verified parser against live AMS: PLA Black → slot 3 (30g) over slot 1 (1000g)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
147 lines
4.7 KiB
Python
147 lines
4.7 KiB
Python
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'<h4>Slot (\d+)</h4>', card)
|
|
matcol_m = re.search(
|
|
r'<strong>.*?</strong><br>\s*(.*?)<br>\s*(.*?)\s*</p>', 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)
|