Log filament usage to FilamentDB when starting a job

- 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>
This commit is contained in:
Martin Hohenberg
2026-06-18 22:59:33 +02:00
parent bef57027f2
commit 17097061c3
2 changed files with 93 additions and 2 deletions

View File

@@ -1,5 +1,7 @@
import logging import logging
import math
import os import os
import re
import time import time
import httpx import httpx
@@ -64,6 +66,70 @@ def spool_counts() -> dict[tuple[str, str], int]:
return result 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]: def available_colors(material: str | None = None) -> list[str]:
"""Unique color names in stock, optionally filtered by material.""" """Unique color names in stock, optionally filtered by material."""
rows = get_stock() rows = get_stock()

View File

@@ -1,3 +1,4 @@
import math
import os import os
import uuid as _uuid import uuid as _uuid
from datetime import datetime, timedelta from datetime import datetime, timedelta
@@ -21,8 +22,9 @@ from app.models.printer_type import PrinterType
from app.models.pricing import PricingConfig from app.models.pricing import PricingConfig
from app.models.todo import Todo from app.models.todo import Todo
from app.constants import COLORS, MATERIALS from app.constants import COLORS, MATERIALS
from app.estimation import estimate from app.estimation import estimate # also used in start_job
from app.filamentdb import available_colors, available_materials, get_stock, spool_counts from app.filamentdb import (available_colors, available_materials, find_matching_ams_roll,
get_stock, log_print_on_roll, spool_counts)
from app.stl import get_stl_dir, list_stl_files from app.stl import get_stl_dir, list_stl_files
from app.stl_analysis import analyse, get_cache_map # noqa: F401 used in job_detail route from app.stl_analysis import analyse, get_cache_map # noqa: F401 used in job_detail route
@@ -325,6 +327,29 @@ def start_job(job_id: int, printer_id: int = Form(...), db: Session = Depends(ge
printer.status = PrinterStatus.printing printer.status = PrinterStatus.printing
db.add(JobLog(job_id=job_id, message=f"Job started on {printer.name}")) db.add(JobLog(job_id=job_id, message=f"Job started on {printer.name}"))
# Log filament usage against matching AMS roll in FilamentDB
if job.job_material and job.job_color:
stl = db.query(StlCache).filter_by(filename=job.file_name).first() if job.file_name else None
if stl and stl.volume_ccm:
weight_g = math.ceil(estimate(stl.volume_ccm, job.job_material)["weight_g"])
roll = find_matching_ams_roll(job.job_material, job.job_color)
if roll:
note = (f"POPS {job.job_uuid[:8] if job.job_uuid else job.id}: "
f"{job.file_name or job.name} on {printer.name}")
if log_print_on_roll(roll["roll_id"], weight_g, note):
db.add(JobLog(job_id=job_id, message=(
f"FilamentDB: logged {weight_g} g against roll #{roll['roll_id']} "
f"(AMS slot {roll['slot']}{roll['material']} {roll['color']}, "
f"{roll['weight_remaining_g']} g remaining, least-full roll selected)"
)))
else:
db.add(JobLog(job_id=job_id, message=f"FilamentDB: print log failed for roll #{roll['roll_id']}"))
else:
db.add(JobLog(job_id=job_id, message=(
f"FilamentDB: no {job.job_material} {job.job_color} roll found in AMS"
)))
db.commit() db.commit()
return RedirectResponse(url=f"/jobs/{job_id}", status_code=HTTP_303_SEE_OTHER) return RedirectResponse(url=f"/jobs/{job_id}", status_code=HTTP_303_SEE_OTHER)