mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-19 07:33:18 +02:00
The pre-print deficit warning from #720 only ran inside the PrintModal submit flow. Both the green ▶ button on a staged queue row (POST /queue/{id}/start) and the Virtual Printer queue-mode intake bypassed it — auto_dispatch=True VP intakes would dispatch unsupervised onto spools that physically can't complete the print. Extracted the deficit check into backend/app/services/filament_deficit.py (single source of truth, both internal inventory and Spoolman modes). POST /queue/{id}/start returns 409 with a structured deficit payload unless ?skip_filament_check=true. The dispatch scheduler runs the same check before each _start_print; a deficit promotes the item to manual_start + sets a new filament_short flag (idempotent migration on print_queue). The flag clears automatically on the next tick when the operator swaps a spool to one with enough material. Frontend ▶ catches the 409 and opens a confirm modal showing each shorted slot's required vs remaining grams; the row now renders a yellow "Insufficient filament" badge when filament_short is set. Translated across all 9 locales.
288 lines
10 KiB
Python
288 lines
10 KiB
Python
"""Filament-deficit check used by every queue dispatch path.
|
|
|
|
The PrintModal warns when an assigned spool can't satisfy a print's per-slot
|
|
filament weight (``Pre-print checks now also warn when the spool has
|
|
insufficient material`` — #720). That check only runs when the user clicks
|
|
"Print" inside PrintModal; ``QueuePage`` Play button, ``start_queue_item``
|
|
route, and the VP intake + scheduler auto-dispatch path all skip it (#1496).
|
|
|
|
This module is the single source of truth for the check. Both the route
|
|
handler (``POST /print-queue/{id}/start``) and the dispatch scheduler call
|
|
``compute_deficit_for_queue_item`` against live spool state.
|
|
|
|
Design notes:
|
|
* The 3MF parser is the same one used by PrintModal: per-slot ``used_grams``
|
|
comes from ``extract_filament_requirements`` (#1188's filament-overrides
|
|
pipeline) or — when the item points at an unsliced library file — falls
|
|
through to the file's archive copy. Anything that yields no requirements
|
|
is treated as "no deficit" so a malformed or stripped 3MF never blocks.
|
|
* Both internal-inventory and Spoolman modes are covered. Internal mode
|
|
resolves via ``SpoolAssignment`` joined to ``Spool`` (``label_weight``
|
|
minus ``weight_used``). Spoolman mode resolves via
|
|
``SpoolmanSlotAssignment`` then ``SpoolmanClient.get_spool`` for the live
|
|
remaining weight; if Spoolman is unreachable we return no deficit rather
|
|
than wedge the queue on a flaky network call.
|
|
* The ``disable_filament_warnings`` user setting is respected at the
|
|
service boundary — callers do not have to know about it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from dataclasses import dataclass
|
|
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 as app_settings
|
|
from backend.app.models.print_queue import PrintQueueItem
|
|
from backend.app.models.spool_assignment import SpoolAssignment
|
|
from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
|
|
from backend.app.services.filament_requirements import extract_filament_requirements
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FilamentDeficit:
|
|
"""One slot's filament shortfall."""
|
|
|
|
slot_id: int
|
|
ams_id: int | None
|
|
tray_id: int | None
|
|
filament_type: str
|
|
required_grams: float
|
|
remaining_grams: float | None # None = could not determine
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"slot_id": self.slot_id,
|
|
"ams_id": self.ams_id,
|
|
"tray_id": self.tray_id,
|
|
"filament_type": self.filament_type,
|
|
"required_grams": self.required_grams,
|
|
"remaining_grams": self.remaining_grams,
|
|
}
|
|
|
|
|
|
def _global_to_ams_key(global_tray_id: int) -> tuple[int, int]:
|
|
"""Inverse of ``ams_id * 4 + tray_id`` — matches ``usage_tracker``."""
|
|
if global_tray_id >= 254:
|
|
return (255, global_tray_id - 254)
|
|
if global_tray_id >= 128:
|
|
return (global_tray_id, 0)
|
|
return (global_tray_id // 4, global_tray_id % 4)
|
|
|
|
|
|
def _resolve_source_3mf(item: PrintQueueItem) -> Path | None:
|
|
"""Locate the 3MF file backing this queue item (archive or library)."""
|
|
if item.archive is not None and item.archive.file_path:
|
|
return app_settings.base_dir / item.archive.file_path
|
|
if item.library_file is not None and item.library_file.file_path:
|
|
return Path(item.library_file.file_path)
|
|
return None
|
|
|
|
|
|
async def _spoolman_remaining_grams(spoolman_spool_id: int) -> float | None:
|
|
"""Live remaining grams for a Spoolman spool, or None if unavailable."""
|
|
try:
|
|
from backend.app.services.spoolman import (
|
|
SpoolmanClientError,
|
|
SpoolmanNotFoundError,
|
|
get_spoolman_client,
|
|
)
|
|
except ImportError:
|
|
return None
|
|
try:
|
|
client = await get_spoolman_client()
|
|
if client is None:
|
|
return None
|
|
spool = await client.get_spool(spoolman_spool_id)
|
|
except (SpoolmanNotFoundError, SpoolmanClientError):
|
|
return None
|
|
except Exception as e:
|
|
logger.debug("Spoolman fetch failed for spool %s: %s", spoolman_spool_id, e)
|
|
return None
|
|
|
|
if not spool:
|
|
return None
|
|
|
|
# Spoolman exposes either an absolute remaining_weight, or used_weight +
|
|
# filament.weight. Either is sufficient — prefer remaining_weight when
|
|
# present (the user may have overridden it).
|
|
remaining = spool.get("remaining_weight")
|
|
if isinstance(remaining, (int, float)) and remaining >= 0:
|
|
return float(remaining)
|
|
|
|
used = spool.get("used_weight")
|
|
filament = spool.get("filament") or {}
|
|
total = filament.get("weight")
|
|
if isinstance(used, (int, float)) and isinstance(total, (int, float)) and total > 0:
|
|
return max(0.0, float(total) - float(used))
|
|
|
|
return None
|
|
|
|
|
|
async def _is_spoolman_mode(db: AsyncSession) -> bool:
|
|
"""Check whether the user has opted in to Spoolman inventory mode."""
|
|
try:
|
|
from backend.app.api.routes.settings import get_setting
|
|
|
|
spoolman_enabled = await get_setting(db, "spoolman_enabled")
|
|
return bool(spoolman_enabled) and spoolman_enabled.lower() == "true"
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
async def _warnings_disabled(db: AsyncSession) -> bool:
|
|
"""Honour the ``disable_filament_warnings`` setting (#720)."""
|
|
try:
|
|
from backend.app.api.routes.settings import get_setting
|
|
|
|
disabled = await get_setting(db, "disable_filament_warnings")
|
|
return bool(disabled) and disabled.lower() == "true"
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _parse_ams_mapping(raw: str | None) -> list[int] | None:
|
|
if not raw:
|
|
return None
|
|
try:
|
|
parsed = json.loads(raw)
|
|
except (json.JSONDecodeError, TypeError):
|
|
return None
|
|
if not isinstance(parsed, list):
|
|
return None
|
|
return [v for v in parsed if isinstance(v, int)]
|
|
|
|
|
|
async def compute_deficit_for_queue_item(
|
|
db: AsyncSession,
|
|
item: PrintQueueItem,
|
|
) -> list[FilamentDeficit]:
|
|
"""Return per-slot filament shortfalls for ``item``, or [] when it's safe to dispatch.
|
|
|
|
Returns an empty list whenever any of the following hold:
|
|
|
|
* The ``disable_filament_warnings`` setting is on.
|
|
* The item has no resolved ``printer_id`` (model-based assignment not
|
|
yet picked a printer — the scheduler re-runs the check after it does).
|
|
* No source 3MF is available, or the 3MF carries no per-slot
|
|
requirements (treated as "nothing to verify" rather than an error,
|
|
matching the PrintModal behaviour).
|
|
* No AMS mapping is set yet — the scheduler computes the mapping just
|
|
before dispatch; until it does we cannot map slot → tray.
|
|
* Spoolman mode is on but the Spoolman server is unreachable. We do not
|
|
wedge the queue on a network blip.
|
|
"""
|
|
if await _warnings_disabled(db):
|
|
return []
|
|
if item.printer_id is None:
|
|
return []
|
|
|
|
# Refresh the relationships we need without assuming the caller eagerly
|
|
# loaded them — both the route and the scheduler call this from contexts
|
|
# with different loading strategies.
|
|
refreshed = await db.execute(
|
|
select(PrintQueueItem)
|
|
.options(
|
|
selectinload(PrintQueueItem.archive),
|
|
selectinload(PrintQueueItem.library_file),
|
|
)
|
|
.where(PrintQueueItem.id == item.id)
|
|
)
|
|
item = refreshed.scalar_one_or_none() or item
|
|
|
|
source_path = _resolve_source_3mf(item)
|
|
if source_path is None or not source_path.exists():
|
|
return []
|
|
|
|
requirements = extract_filament_requirements(source_path, item.plate_id)
|
|
if not requirements:
|
|
return []
|
|
|
|
mapping = _parse_ams_mapping(item.ams_mapping)
|
|
if not mapping:
|
|
return []
|
|
|
|
spoolman_mode = await _is_spoolman_mode(db)
|
|
|
|
deficits: list[FilamentDeficit] = []
|
|
for req in requirements:
|
|
slot_id = req.get("slot_id")
|
|
used_grams = req.get("used_grams")
|
|
if not isinstance(slot_id, int) or slot_id <= 0:
|
|
continue
|
|
if not isinstance(used_grams, (int, float)) or used_grams <= 0:
|
|
continue
|
|
idx = slot_id - 1
|
|
if idx >= len(mapping):
|
|
continue
|
|
global_tray_id = mapping[idx]
|
|
if global_tray_id is None or global_tray_id < 0:
|
|
continue
|
|
ams_id, tray_id = _global_to_ams_key(global_tray_id)
|
|
|
|
remaining: float | None = None
|
|
if spoolman_mode:
|
|
sm_result = await db.execute(
|
|
select(SpoolmanSlotAssignment).where(
|
|
SpoolmanSlotAssignment.printer_id == item.printer_id,
|
|
SpoolmanSlotAssignment.ams_id == ams_id,
|
|
SpoolmanSlotAssignment.tray_id == tray_id,
|
|
)
|
|
)
|
|
sm_assignment = sm_result.scalar_one_or_none()
|
|
if sm_assignment is None:
|
|
continue
|
|
remaining = await _spoolman_remaining_grams(sm_assignment.spoolman_spool_id)
|
|
else:
|
|
internal_result = await db.execute(
|
|
select(SpoolAssignment)
|
|
.options(selectinload(SpoolAssignment.spool))
|
|
.where(
|
|
SpoolAssignment.printer_id == item.printer_id,
|
|
SpoolAssignment.ams_id == ams_id,
|
|
SpoolAssignment.tray_id == tray_id,
|
|
)
|
|
)
|
|
assignment = internal_result.scalar_one_or_none()
|
|
if assignment is None or assignment.spool is None:
|
|
continue
|
|
spool = assignment.spool
|
|
label_weight = float(spool.label_weight or 0)
|
|
weight_used = float(spool.weight_used or 0)
|
|
if label_weight <= 0:
|
|
continue
|
|
remaining = max(0.0, label_weight - weight_used)
|
|
|
|
if remaining is None:
|
|
# Spoolman unreachable for this spool — skip rather than block.
|
|
continue
|
|
if remaining >= float(used_grams):
|
|
continue
|
|
|
|
deficits.append(
|
|
FilamentDeficit(
|
|
slot_id=slot_id,
|
|
ams_id=ams_id,
|
|
tray_id=tray_id,
|
|
filament_type=str(req.get("type", "")),
|
|
required_grams=float(used_grams),
|
|
remaining_grams=remaining,
|
|
)
|
|
)
|
|
|
|
return deficits
|
|
|
|
|
|
# Re-export the most useful pieces for callers that just want the data.
|
|
__all__ = [
|
|
"FilamentDeficit",
|
|
"compute_deficit_for_queue_item",
|
|
]
|