mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
Turning Spoolman mode on ran an unfiltered delete(SpoolAssignment) across every printer. Turning it straight back off cleared the other table instead, so the two directions were symmetric in code and one-way in effect, and the setting auto-saves on a 500 ms debounce with no save button and no confirmation. Opening the settings page to see what the option did was enough to destroy the configuration: the reporter's log shows four toggles in 85 seconds, which is someone looking and reverting, and the assignments never came back. The deletion was not careless. Checks that read both assignment tables would otherwise let a row in the mode you are not using answer for the mode you are, which is how #1473 was fixed, and emptying the inactive table made that impossible by construction. The cost was that the guarantee was bought with the user's data. That decision belongs to the readers -- the mode is a property of the install, not of the rows -- so spoolman_owns_assignments now answers it and nothing is deleted on a toggle. Each mode keeps its own assignments and switching is reversible. Existing installs need no migration: their inactive table is already empty, because it was being emptied. Six sites had to be told which mode they meant, and only two of them are the reads you would guess at, the missing-assignment notification and the queue cost estimate. The per-slot K-profile lookup consults the built-in table first and, on a hit with no matching profile, deliberately stops rather than falling through to Spoolman, so a leftover row would have shadowed the Spoolman binding for that slot -- the symptom #1556 reported from the other direction. configure_ams_slot *writes* a K-profile against whichever table answers first, so the same leftover would have filed a calibration against a spool the printer is not drawing on and never written the local one, leaving a calibration that appeared to succeed and then did not apply. The auto-unlink pass in on_ams_change is the one that would have made this change worthless. It drops any assignment whose tray no longer matches the fingerprint it recorded, and it ends in db.delete. Ungated, it would have removed the preserved rows one slot at a time as the AMS contents changed under the other mode -- the same loss, arriving slowly enough not to be connected to the toggle that caused it. The sixth is the built-in remaining-weight fallback inside the Spoolman AMS sync, and it is deliberately left inert rather than woken up. It could never fire while the table it reads was being emptied, it is keyed by slot rather than by spool, and create_spool writes remaining_weight unconditionally where the update path does not -- so preserving the rows would have seeded a stale figure into a brand new Spoolman spool the first time a tray reported an unusable remain%. The query stays, gated off, so the intent survives for whoever revisits the cross-mode fallback. Separately, a print that could not debit a spool said nothing about it, and that is what turned a mis-click into lost filament. The reporter's print was already running when they toggled. At completion it resolved its 3MF, read its per-filament grams, resolved its tray, and then skipped the debit because the assignment row no longer existed -- logged at INFO, invisible under the default log level, while the completion notification fired as usual. 65.49 g was never deducted and they only noticed because a spool's remaining weight looked wrong. _resolve_spool_id_for_tray has no tag or fingerprint fallback, so there was nothing else to catch it. The skip is now a warning naming the grams, and a completed print that failed to charge a tray it drew from raises the missing-spool-assignment notification. The print-start check cannot cover this and was right to stay quiet: the assignments existed when it ran. The two are different statements -- the first says the weight may not be tracked, the second says it was not -- so a print warned at start will notify twice, which is the right trade. Collected across the print rather than fired per slot, and given the caller's session, because this runs inside on_print_complete's transaction and opening a second one to read the printer's name would deadlock against it on SQLite. This is independent of the toggle and catches any other cause of an assignment disappearing mid-print.
171 lines
6.2 KiB
Python
171 lines
6.2 KiB
Python
"""Trusted server-side cost estimates for queued prints."""
|
|
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from backend.app.core.config import settings
|
|
from backend.app.models.archive import PrintArchive
|
|
from backend.app.models.library import LibraryFile
|
|
from backend.app.models.spool_assignment import SpoolAssignment
|
|
from backend.app.services.inventory_mode import spoolman_owns_assignments
|
|
from backend.app.utils import threemf_tools
|
|
from backend.app.utils.safe_path import safe_join_under
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def plate_scoped_run_estimate(
|
|
archive: PrintArchive,
|
|
full_path: Path | None,
|
|
plate_id: int | None = None,
|
|
) -> tuple[float | None, float | None]:
|
|
"""Return trusted ``(grams, cost)`` for one run of an archive plate."""
|
|
|
|
whole_grams = archive.filament_used_grams
|
|
selected_plate = archive.plate_id if plate_id is None else plate_id
|
|
if selected_plate is None or full_path is None or not full_path.exists():
|
|
return whole_grams, archive.cost
|
|
try:
|
|
plate_grams = threemf_tools.extract_plate_metadata_from_3mf(full_path, selected_plate).filament_used_grams
|
|
except Exception as exc:
|
|
logger.debug(
|
|
"Plate-scoped estimate failed for archive %s (plate %s): %s",
|
|
archive.id,
|
|
selected_plate,
|
|
exc,
|
|
)
|
|
return whole_grams, archive.cost
|
|
if not plate_grams or plate_grams <= 0:
|
|
return whole_grams, archive.cost
|
|
plate_cost = archive.cost
|
|
if archive.cost and whole_grams and whole_grams > 0:
|
|
plate_cost = round(archive.cost * (plate_grams / whole_grams), 2)
|
|
return round(plate_grams, 2), plate_cost
|
|
|
|
|
|
def _source_path(library_file: LibraryFile) -> Path:
|
|
path = Path(library_file.file_path)
|
|
if path.is_absolute():
|
|
# SEC-PATH-OK: absolute paths are persisted LibraryFile locations for
|
|
# configured external libraries; this branch performs no path join.
|
|
return path
|
|
return safe_join_under(settings.base_dir, library_file.file_path, http=False)
|
|
|
|
|
|
def _parse_mapping(mapping: list[int] | str | None) -> list[int] | None:
|
|
if isinstance(mapping, list):
|
|
return mapping
|
|
if isinstance(mapping, str):
|
|
try:
|
|
parsed = json.loads(mapping)
|
|
except (TypeError, json.JSONDecodeError):
|
|
return None
|
|
return parsed if isinstance(parsed, list) else None
|
|
return None
|
|
|
|
|
|
def _global_tray_id(assignment: SpoolAssignment) -> int:
|
|
if assignment.ams_id == 255:
|
|
return 254 + assignment.tray_id
|
|
if assignment.ams_id >= 128:
|
|
return assignment.ams_id
|
|
return assignment.ams_id * 4 + assignment.tray_id
|
|
|
|
|
|
async def _default_cost_per_kg(db: AsyncSession) -> float:
|
|
from backend.app.api.routes.settings import get_setting
|
|
|
|
raw = await get_setting(db, "default_filament_cost")
|
|
try:
|
|
return float(raw) if raw is not None else 25.0
|
|
except (TypeError, ValueError):
|
|
return 25.0
|
|
|
|
|
|
async def estimate_queue_source_cost(
|
|
db: AsyncSession,
|
|
*,
|
|
archive: PrintArchive | None = None,
|
|
library_file: LibraryFile | None = None,
|
|
plate_id: int | None = None,
|
|
ams_mapping: list[int] | str | None = None,
|
|
printer_id: int | None = None,
|
|
) -> float | None:
|
|
"""Compute a queue cost without trusting the request's display hint."""
|
|
|
|
if archive is not None:
|
|
archive_path = settings.base_dir / archive.file_path
|
|
grams, cost = plate_scoped_run_estimate(archive, archive_path, plate_id)
|
|
if cost is not None and cost > 0:
|
|
return float(cost)
|
|
# Older archives and imports can have trustworthy filament usage but
|
|
# no stored cost. Model-based and multi-printer jobs have no single
|
|
# spool mapping at enqueue time, so use the server setting rather than
|
|
# requiring the browser to provide an estimate.
|
|
if grams is None or grams <= 0:
|
|
return None
|
|
default_cost = await _default_cost_per_kg(db)
|
|
estimated_cost = (grams / 1000.0) * default_cost
|
|
return max(0.01, round(estimated_cost, 2)) if estimated_cost > 0 else None
|
|
|
|
if library_file is None:
|
|
return None
|
|
|
|
path = _source_path(library_file)
|
|
usage: list[dict] = []
|
|
if path.exists():
|
|
usage = threemf_tools.extract_plate_metadata_from_3mf(path, plate_id).filament_usage
|
|
|
|
metadata = library_file.file_metadata or {}
|
|
if not usage:
|
|
try:
|
|
grams = float(metadata.get("filament_used_grams") or 0)
|
|
except (TypeError, ValueError):
|
|
grams = 0
|
|
if grams > 0:
|
|
usage = [{"slot_id": 1, "used_g": grams}]
|
|
|
|
if not usage:
|
|
return None
|
|
|
|
default_cost = await _default_cost_per_kg(db)
|
|
cost_by_tray: dict[int, float | None] = {}
|
|
mapping = _parse_mapping(ams_mapping)
|
|
# Built-in spool prices only. In Spoolman mode the built-in table may still
|
|
# hold rows from before the user switched -- nothing clears it since #2812 --
|
|
# and pricing an estimate from a spool the printer is not drawing on would
|
|
# be worse than the default rate this falls back to.
|
|
if printer_id is not None and mapping and not await spoolman_owns_assignments(db):
|
|
assignments = (
|
|
(
|
|
await db.execute(
|
|
select(SpoolAssignment)
|
|
.options(selectinload(SpoolAssignment.spool))
|
|
.where(SpoolAssignment.printer_id == printer_id)
|
|
)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
cost_by_tray = {_global_tray_id(a): a.spool.cost_per_kg for a in assignments}
|
|
|
|
total = 0.0
|
|
for filament in usage:
|
|
try:
|
|
slot_id = int(filament.get("slot_id") or 0)
|
|
grams = float(filament.get("used_g") or 0)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
tray_id = mapping[slot_id - 1] if mapping and 0 < slot_id <= len(mapping) else None
|
|
cost_per_kg = cost_by_tray.get(tray_id) if tray_id is not None else None
|
|
if cost_per_kg is None or cost_per_kg <= 0:
|
|
cost_per_kg = default_cost
|
|
total += (grams / 1000.0) * cost_per_kg
|
|
|
|
return round(total, 2) if total > 0 else None
|