mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
Add notification for no spool assigned for active trays, improve usage tracker logic in edge cases (#789)
Add notification for no spool assigned for active trays, improve usage tracker logic in edge cases (#789)
This commit is contained in:
@@ -43,6 +43,7 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
|
||||
"on_print_failed": provider.on_print_failed,
|
||||
"on_print_stopped": provider.on_print_stopped,
|
||||
"on_print_progress": provider.on_print_progress,
|
||||
"on_print_missing_spool_assignment": provider.on_print_missing_spool_assignment,
|
||||
# Printer status events
|
||||
"on_printer_offline": provider.on_printer_offline,
|
||||
"on_printer_error": provider.on_printer_error,
|
||||
@@ -122,6 +123,7 @@ async def create_notification_provider(
|
||||
on_print_failed=provider_data.on_print_failed,
|
||||
on_print_stopped=provider_data.on_print_stopped,
|
||||
on_print_progress=provider_data.on_print_progress,
|
||||
on_print_missing_spool_assignment=provider_data.on_print_missing_spool_assignment,
|
||||
# Printer status events
|
||||
on_printer_offline=provider_data.on_printer_offline,
|
||||
on_printer_error=provider_data.on_printer_error,
|
||||
|
||||
@@ -254,6 +254,14 @@ async def run_migrations(conn):
|
||||
except OperationalError:
|
||||
pass # Already applied
|
||||
|
||||
# Migration: Add missing-spool-assignment print-start notification toggle
|
||||
try:
|
||||
await conn.execute(
|
||||
text("ALTER TABLE notification_providers ADD COLUMN on_print_missing_spool_assignment BOOLEAN DEFAULT 0")
|
||||
)
|
||||
except OperationalError:
|
||||
pass # Already applied
|
||||
|
||||
# Migration: Add project_id column to print_archives
|
||||
try:
|
||||
await conn.execute(
|
||||
|
||||
@@ -91,6 +91,22 @@ class ConnectionManager:
|
||||
}
|
||||
)
|
||||
|
||||
async def send_missing_spool_assignment(
|
||||
self,
|
||||
printer_id: int,
|
||||
printer_name: str,
|
||||
missing_slots: list[dict[str, str]],
|
||||
):
|
||||
"""Notify clients that a print started with missing spool assignments."""
|
||||
await self.broadcast(
|
||||
{
|
||||
"type": "missing_spool_assignment",
|
||||
"printer_id": printer_id,
|
||||
"printer_name": printer_name,
|
||||
"missing_slots": missing_slots,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Global connection manager
|
||||
ws_manager = ConnectionManager()
|
||||
|
||||
@@ -73,6 +73,9 @@ from backend.app.services.printer_manager import (
|
||||
printer_state_to_dict,
|
||||
)
|
||||
from backend.app.services.smart_plug_manager import smart_plug_manager
|
||||
from backend.app.services.spool_assignment_notifications import (
|
||||
notify_missing_spool_assignments_on_print_start,
|
||||
)
|
||||
from backend.app.services.spoolman import close_spoolman_client, get_spoolman_client, init_spoolman_client
|
||||
from backend.app.services.spoolman_tracking import (
|
||||
cleanup_tracking as _cleanup_spoolman_tracking,
|
||||
@@ -1214,6 +1217,9 @@ async def on_print_start(printer_id: int, data: dict):
|
||||
|
||||
await ws_manager.send_print_start(printer_id, data)
|
||||
|
||||
# Notify when the print-start AMS mapping references tray slots without spool assignments.
|
||||
await notify_missing_spool_assignments_on_print_start(printer_id, data, logger)
|
||||
|
||||
# MQTT relay - publish print start
|
||||
try:
|
||||
printer_info = printer_manager.get_printer(printer_id)
|
||||
|
||||
@@ -65,6 +65,7 @@ class NotificationProvider(Base):
|
||||
on_print_failed = Column(Boolean, default=True)
|
||||
on_print_stopped = Column(Boolean, default=True) # User cancelled/stopped print
|
||||
on_print_progress = Column(Boolean, default=False) # 25%, 50%, 75% milestones
|
||||
on_print_missing_spool_assignment = Column(Boolean, default=False) # Print started with unassigned required tray(s)
|
||||
|
||||
# Event triggers - printer status
|
||||
on_printer_offline = Column(Boolean, default=False)
|
||||
|
||||
@@ -55,6 +55,12 @@ DEFAULT_TEMPLATES = [
|
||||
"title_template": "Print {progress}% Complete",
|
||||
"body_template": "{printer}: {filename}\nRemaining: {remaining_time}",
|
||||
},
|
||||
{
|
||||
"event_type": "print_missing_spool_assignment",
|
||||
"name": "Missing Spool Assignment",
|
||||
"title_template": "Missing Spool Assignment",
|
||||
"body_template": "{printer}: print started with missing spool assignments\nSlots: {missing_slots}\nExpected profile:\n{missing_slot_details}",
|
||||
},
|
||||
{
|
||||
"event_type": "printer_offline",
|
||||
"name": "Printer Offline",
|
||||
|
||||
@@ -35,6 +35,10 @@ class NotificationProviderBase(BaseModel):
|
||||
on_print_failed: bool = Field(default=True, description="Notify on print failed")
|
||||
on_print_stopped: bool = Field(default=True, description="Notify when print is stopped/cancelled")
|
||||
on_print_progress: bool = Field(default=False, description="Notify at 25%, 50%, 75% progress")
|
||||
on_print_missing_spool_assignment: bool = Field(
|
||||
default=False,
|
||||
description="Notify when a print starts with required trays missing spool assignments",
|
||||
)
|
||||
|
||||
# Event triggers - printer status
|
||||
on_printer_offline: bool = Field(default=False, description="Notify when printer goes offline")
|
||||
@@ -119,6 +123,7 @@ class NotificationProviderUpdate(BaseModel):
|
||||
on_print_failed: bool | None = None
|
||||
on_print_stopped: bool | None = None
|
||||
on_print_progress: bool | None = None
|
||||
on_print_missing_spool_assignment: bool | None = None
|
||||
|
||||
# Event triggers - printer status
|
||||
on_printer_offline: bool | None = None
|
||||
|
||||
@@ -15,6 +15,7 @@ class EventType(StrEnum):
|
||||
PRINT_FAILED = "print_failed"
|
||||
PRINT_STOPPED = "print_stopped"
|
||||
PRINT_PROGRESS = "print_progress"
|
||||
PRINT_MISSING_SPOOL_ASSIGNMENT = "print_missing_spool_assignment"
|
||||
PRINTER_OFFLINE = "printer_offline"
|
||||
PRINTER_ERROR = "printer_error"
|
||||
FILAMENT_LOW = "filament_low"
|
||||
@@ -62,6 +63,13 @@ EVENT_VARIABLES: dict[str, list[str]] = {
|
||||
"app_name",
|
||||
],
|
||||
"print_progress": ["printer", "filename", "progress", "remaining_time", "eta", "timestamp", "app_name"],
|
||||
"print_missing_spool_assignment": [
|
||||
"printer",
|
||||
"missing_slots",
|
||||
"missing_slot_details",
|
||||
"timestamp",
|
||||
"app_name",
|
||||
],
|
||||
"printer_offline": ["printer", "timestamp", "app_name"],
|
||||
"printer_error": ["printer", "error_type", "error_detail", "timestamp", "app_name"],
|
||||
"filament_low": ["printer", "slot", "remaining_percent", "color", "timestamp", "app_name"],
|
||||
@@ -140,6 +148,13 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
|
||||
"timestamp": "2024-01-15 15:00",
|
||||
"app_name": "Bambuddy",
|
||||
},
|
||||
"print_missing_spool_assignment": {
|
||||
"printer": "Bambu X1C",
|
||||
"missing_slots": "A1, A3",
|
||||
"missing_slot_details": "- A1: PLA Basic\n- A3: PETG HF",
|
||||
"timestamp": "2024-01-15 14:30",
|
||||
"app_name": "Bambuddy",
|
||||
},
|
||||
"printer_offline": {
|
||||
"printer": "Bambu X1C",
|
||||
"timestamp": "2024-01-15 14:30",
|
||||
|
||||
@@ -934,6 +934,47 @@ class NotificationService:
|
||||
providers, title, message, db, "print_progress", printer_id, printer_name, image_data=image_data
|
||||
)
|
||||
|
||||
async def on_print_missing_spool_assignment(
|
||||
self,
|
||||
printer_id: int,
|
||||
printer_name: str,
|
||||
missing_slots: list[dict[str, str]],
|
||||
db: AsyncSession,
|
||||
):
|
||||
"""Handle print-start event when required trays are missing spool assignments."""
|
||||
if not missing_slots:
|
||||
return
|
||||
|
||||
providers = await self._get_providers_for_event(db, "on_print_missing_spool_assignment", printer_id)
|
||||
if not providers:
|
||||
return
|
||||
|
||||
missing_slot_names = ", ".join(slot.get("slot", "Unknown") for slot in missing_slots)
|
||||
detail_lines = []
|
||||
for slot in missing_slots:
|
||||
slot_name = slot.get("slot", "Unknown")
|
||||
profile = slot.get("profile", "Unknown")
|
||||
detail_lines.append(f"- {slot_name}: {profile}")
|
||||
missing_profile_details = "\n".join(detail_lines)
|
||||
|
||||
variables = {
|
||||
"printer": printer_name,
|
||||
"missing_slots": missing_slot_names,
|
||||
"missing_slot_details": missing_profile_details,
|
||||
}
|
||||
|
||||
title, message = await self._build_message_from_template(db, "print_missing_spool_assignment", variables)
|
||||
await self._send_to_providers(
|
||||
providers,
|
||||
title,
|
||||
message,
|
||||
db,
|
||||
"print_missing_spool_assignment",
|
||||
printer_id,
|
||||
printer_name,
|
||||
force_immediate=True,
|
||||
)
|
||||
|
||||
async def on_printer_offline(self, printer_id: int, printer_name: str, db: AsyncSession):
|
||||
"""Handle printer offline event."""
|
||||
providers = await self._get_providers_for_event(db, "on_printer_offline", printer_id)
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import logging
|
||||
|
||||
from backend.app.core.database import async_session
|
||||
from backend.app.core.websocket import ws_manager
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.models.spool_assignment import SpoolAssignment
|
||||
from backend.app.services.bambu_mqtt import PrinterState
|
||||
from backend.app.services.notification_service import notification_service
|
||||
from backend.app.services.printer_manager import printer_manager
|
||||
|
||||
|
||||
def _global_tray_from_assignment(ams_id: int, tray_id: int) -> int:
|
||||
"""Convert an assignment tuple to Bambuddy global tray ID."""
|
||||
if ams_id in (254, 255):
|
||||
return 254 + tray_id
|
||||
if ams_id >= 128:
|
||||
return ams_id
|
||||
return ams_id * 4 + tray_id
|
||||
|
||||
|
||||
def _slot_label_from_global_tray(global_tray_id: int) -> str:
|
||||
"""Return a human-readable slot label from a global tray ID."""
|
||||
if global_tray_id == 254:
|
||||
return "Ext-L"
|
||||
if global_tray_id == 255:
|
||||
return "Ext-R"
|
||||
if global_tray_id >= 128:
|
||||
return f"HT-{chr(65 + (global_tray_id - 128))}"
|
||||
ams_id = global_tray_id // 4
|
||||
tray_id = global_tray_id % 4
|
||||
return f"{chr(65 + ams_id)}{tray_id + 1}"
|
||||
|
||||
|
||||
def _tray_profile_and_color_for_global_id(state: PrinterState | None, global_tray_id: int) -> tuple[str, str]:
|
||||
"""Resolve expected tray material/profile and color for a global tray ID from current printer state."""
|
||||
if not state or not state.raw_data:
|
||||
return ("Unknown", "Unknown")
|
||||
|
||||
ams_raw = state.raw_data.get("ams", {})
|
||||
ams_units = ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
|
||||
|
||||
vt_trays = state.raw_data.get("vt_tray", [])
|
||||
if not isinstance(vt_trays, list):
|
||||
vt_trays = []
|
||||
|
||||
for tray in vt_trays:
|
||||
if not isinstance(tray, dict):
|
||||
continue
|
||||
if int(tray.get("id", -1)) == global_tray_id:
|
||||
profile = tray.get("tray_sub_brands") or tray.get("tray_type") or "Unknown"
|
||||
color = tray.get("tray_color") or "Unknown"
|
||||
return (profile, color)
|
||||
|
||||
for ams in ams_units:
|
||||
if not isinstance(ams, dict):
|
||||
continue
|
||||
ams_id = int(ams.get("id", -1))
|
||||
trays = ams.get("tray", [])
|
||||
if not isinstance(trays, list):
|
||||
continue
|
||||
for tray in trays:
|
||||
if not isinstance(tray, dict):
|
||||
continue
|
||||
tray_id = int(tray.get("id", -1))
|
||||
candidate = ams_id if ams_id >= 128 else (ams_id * 4 + tray_id)
|
||||
if candidate == global_tray_id:
|
||||
profile = tray.get("tray_sub_brands") or tray.get("tray_type") or "Unknown"
|
||||
color = tray.get("tray_color") or "Unknown"
|
||||
return (profile, color)
|
||||
|
||||
return ("Unknown", "Unknown")
|
||||
|
||||
|
||||
def _decode_mqtt_mapping_to_global_trays(mapping_raw: object) -> list[int]:
|
||||
"""Decode printer MQTT mapping values into Bambuddy global tray IDs."""
|
||||
if not isinstance(mapping_raw, list) or not mapping_raw:
|
||||
return []
|
||||
|
||||
decoded: list[int] = []
|
||||
for value in mapping_raw:
|
||||
try:
|
||||
if isinstance(value, int):
|
||||
encoded = value
|
||||
elif isinstance(value, str):
|
||||
encoded = int(value, 10)
|
||||
else:
|
||||
continue
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if encoded >= 65535:
|
||||
continue
|
||||
|
||||
ams_hw_id = (encoded >> 8) & 0xFF
|
||||
slot = encoded & 0xFF
|
||||
|
||||
if 0 <= ams_hw_id <= 3:
|
||||
decoded.append(ams_hw_id * 4 + (slot & 0x03))
|
||||
elif 128 <= ams_hw_id <= 135:
|
||||
decoded.append(ams_hw_id)
|
||||
elif ams_hw_id in (254, 255):
|
||||
decoded.append(255 if slot == 255 else 254)
|
||||
|
||||
return decoded
|
||||
|
||||
|
||||
async def notify_missing_spool_assignments_on_print_start(
|
||||
printer_id: int,
|
||||
data: dict,
|
||||
logger: logging.Logger,
|
||||
) -> None:
|
||||
"""Send notification when print-start mapping references unassigned trays."""
|
||||
explicit_mapping = data.get("ams_mapping")
|
||||
explicit_values = (
|
||||
[value for value in explicit_mapping if isinstance(value, int)]
|
||||
if isinstance(explicit_mapping, list)
|
||||
else []
|
||||
)
|
||||
raw_mapping = data.get("raw_data", {}).get("mapping") if isinstance(data.get("raw_data"), dict) else None
|
||||
decoded_values = _decode_mqtt_mapping_to_global_trays(raw_mapping)
|
||||
mapping_values = explicit_values if explicit_values else decoded_values
|
||||
|
||||
used_global_trays = {value for value in mapping_values if value >= 0}
|
||||
if not used_global_trays:
|
||||
return
|
||||
|
||||
try:
|
||||
async with async_session() as db:
|
||||
printer = await db.get(Printer, printer_id)
|
||||
printer_name = printer.name if printer else f"Printer {printer_id}"
|
||||
|
||||
assignments_result = await db.execute(
|
||||
SpoolAssignment.__table__.select().where(SpoolAssignment.printer_id == printer_id)
|
||||
)
|
||||
assignments = assignments_result.fetchall()
|
||||
assigned_global_trays = {
|
||||
_global_tray_from_assignment(assignment.ams_id, assignment.tray_id) for assignment in assignments
|
||||
}
|
||||
|
||||
missing_global = sorted(used_global_trays - assigned_global_trays)
|
||||
if not missing_global:
|
||||
return
|
||||
|
||||
state = printer_manager.get_status(printer_id)
|
||||
missing_slots = []
|
||||
for global_id in missing_global:
|
||||
profile, color = _tray_profile_and_color_for_global_id(state, global_id)
|
||||
missing_slots.append(
|
||||
{
|
||||
"slot": _slot_label_from_global_tray(global_id),
|
||||
"profile": profile,
|
||||
"color": color,
|
||||
}
|
||||
)
|
||||
|
||||
await ws_manager.send_missing_spool_assignment(
|
||||
printer_id=printer_id,
|
||||
printer_name=printer_name,
|
||||
missing_slots=missing_slots,
|
||||
)
|
||||
|
||||
await notification_service.on_print_missing_spool_assignment(
|
||||
printer_id=printer_id,
|
||||
printer_name=printer_name,
|
||||
missing_slots=missing_slots,
|
||||
db=db,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Missing spool-assignment notification failed: %s", e)
|
||||
@@ -164,6 +164,70 @@ class PrintSession:
|
||||
_active_sessions: dict[int, PrintSession] = {}
|
||||
|
||||
|
||||
def _to_epoch_seconds(value: datetime | None) -> float | None:
|
||||
"""Convert datetime to epoch seconds, assuming UTC for naive values."""
|
||||
if value is None:
|
||||
return None
|
||||
dt = value
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.timestamp()
|
||||
|
||||
|
||||
async def _resolve_spool_id_for_tray(
|
||||
printer_id: int,
|
||||
ams_id: int,
|
||||
tray_id: int,
|
||||
db: AsyncSession,
|
||||
spool_assignments_snapshot: dict[tuple[int, int], int] | None = None,
|
||||
print_started_at: datetime | None = None,
|
||||
) -> int | None:
|
||||
"""Resolve spool ID for a tray with safe support for mid-print reassignment.
|
||||
|
||||
Resolution order:
|
||||
1. If snapshot exists and live assignment changed *during this print*, use live spool.
|
||||
2. Otherwise use snapshot spool when available.
|
||||
3. Fall back to live assignment.
|
||||
"""
|
||||
key = (ams_id, tray_id)
|
||||
snapshot_spool_id = spool_assignments_snapshot.get(key) if spool_assignments_snapshot else None
|
||||
|
||||
# Backward-compatible fast path: if we have a snapshot but no print-start
|
||||
# timestamp, preserve legacy behavior and avoid extra DB lookups.
|
||||
if snapshot_spool_id is not None and print_started_at is None:
|
||||
return snapshot_spool_id
|
||||
|
||||
result = await db.execute(
|
||||
select(SpoolAssignment).where(
|
||||
SpoolAssignment.printer_id == printer_id,
|
||||
SpoolAssignment.ams_id == ams_id,
|
||||
SpoolAssignment.tray_id == tray_id,
|
||||
)
|
||||
)
|
||||
live_assignment = result.scalar_one_or_none()
|
||||
|
||||
if snapshot_spool_id is not None:
|
||||
if live_assignment and live_assignment.spool_id != snapshot_spool_id:
|
||||
live_created_ts = _to_epoch_seconds(getattr(live_assignment, "created_at", None))
|
||||
started_ts = _to_epoch_seconds(print_started_at)
|
||||
if live_created_ts is not None and started_ts is not None and live_created_ts >= started_ts:
|
||||
logger.info(
|
||||
"[UsageTracker] Assignment changed during print for printer %d AMS%d-T%d: snapshot spool %d -> live spool %d",
|
||||
printer_id,
|
||||
ams_id,
|
||||
tray_id,
|
||||
snapshot_spool_id,
|
||||
live_assignment.spool_id,
|
||||
)
|
||||
return live_assignment.spool_id
|
||||
return snapshot_spool_id
|
||||
|
||||
if live_assignment:
|
||||
return live_assignment.spool_id
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def on_print_start(printer_id: int, data: dict, printer_manager, db: AsyncSession | None = None) -> None:
|
||||
"""Capture AMS tray remain% and spool assignments at print start."""
|
||||
state = printer_manager.get_status(printer_id)
|
||||
@@ -323,6 +387,7 @@ async def on_print_complete(
|
||||
last_layer_num=data.get("last_layer_num", 0),
|
||||
default_filament_cost=default_filament_cost,
|
||||
spool_assignments=session.spool_assignments if session else None,
|
||||
print_started_at=session.started_at if session else None,
|
||||
)
|
||||
results.extend(threemf_results)
|
||||
|
||||
@@ -357,20 +422,16 @@ async def on_print_complete(
|
||||
if delta_pct <= 0:
|
||||
continue # No consumption or tray was refilled
|
||||
|
||||
# Look up spool: prefer snapshot (survives mid-print unlink), fall back to live query
|
||||
spool_id = session.spool_assignments.get(key) if session.spool_assignments else None
|
||||
spool_id = await _resolve_spool_id_for_tray(
|
||||
printer_id=printer_id,
|
||||
ams_id=ams_id,
|
||||
tray_id=tray_id,
|
||||
db=db,
|
||||
spool_assignments_snapshot=session.spool_assignments,
|
||||
print_started_at=session.started_at,
|
||||
)
|
||||
if spool_id is None:
|
||||
result = await db.execute(
|
||||
select(SpoolAssignment).where(
|
||||
SpoolAssignment.printer_id == printer_id,
|
||||
SpoolAssignment.ams_id == ams_id,
|
||||
SpoolAssignment.tray_id == tray_id,
|
||||
)
|
||||
)
|
||||
assignment = result.scalar_one_or_none()
|
||||
if not assignment:
|
||||
continue
|
||||
spool_id = assignment.spool_id
|
||||
continue
|
||||
|
||||
# Load spool
|
||||
spool_result = await db.execute(select(Spool).where(Spool.id == spool_id))
|
||||
@@ -463,6 +524,7 @@ async def _track_from_3mf(
|
||||
last_layer_num: int = 0,
|
||||
default_filament_cost: float = 0.0,
|
||||
spool_assignments: dict[tuple[int, int], int] | None = None,
|
||||
print_started_at: datetime | None = None,
|
||||
) -> list[dict]:
|
||||
"""Track usage from 3MF per-filament slicer data (primary path).
|
||||
|
||||
@@ -726,26 +788,22 @@ async def _track_from_3mf(
|
||||
segment_grams,
|
||||
)
|
||||
|
||||
# Find spool for this tray
|
||||
seg_spool_id = spool_assignments.get(seg_key) if spool_assignments else None
|
||||
seg_spool_id = await _resolve_spool_id_for_tray(
|
||||
printer_id=printer_id,
|
||||
ams_id=seg_ams_id,
|
||||
tray_id=seg_tray_id,
|
||||
db=db,
|
||||
spool_assignments_snapshot=spool_assignments,
|
||||
print_started_at=print_started_at,
|
||||
)
|
||||
if seg_spool_id is None:
|
||||
assign_result = await db.execute(
|
||||
select(SpoolAssignment).where(
|
||||
SpoolAssignment.printer_id == printer_id,
|
||||
SpoolAssignment.ams_id == seg_ams_id,
|
||||
SpoolAssignment.tray_id == seg_tray_id,
|
||||
)
|
||||
logger.info(
|
||||
"[UsageTracker] 3MF split: no spool at printer %d AMS%d-T%d, skipping segment",
|
||||
printer_id,
|
||||
seg_ams_id,
|
||||
seg_tray_id,
|
||||
)
|
||||
assignment = assign_result.scalar_one_or_none()
|
||||
if not assignment:
|
||||
logger.info(
|
||||
"[UsageTracker] 3MF split: no spool at printer %d AMS%d-T%d, skipping segment",
|
||||
printer_id,
|
||||
seg_ams_id,
|
||||
seg_tray_id,
|
||||
)
|
||||
continue
|
||||
seg_spool_id = assignment.spool_id
|
||||
continue
|
||||
|
||||
spool_result = await db.execute(select(Spool).where(Spool.id == seg_spool_id))
|
||||
spool = spool_result.scalar_one_or_none()
|
||||
@@ -851,23 +909,17 @@ async def _track_from_3mf(
|
||||
if key in handled_trays:
|
||||
continue
|
||||
|
||||
# Find spool: prefer snapshot (survives mid-print unlink), fall back to live query
|
||||
spool_id = spool_assignments.get(key) if spool_assignments else None
|
||||
spool_id = await _resolve_spool_id_for_tray(
|
||||
printer_id=printer_id,
|
||||
ams_id=ams_id,
|
||||
tray_id=tray_id,
|
||||
db=db,
|
||||
spool_assignments_snapshot=spool_assignments,
|
||||
print_started_at=print_started_at,
|
||||
)
|
||||
if spool_id is None:
|
||||
assign_result = await db.execute(
|
||||
select(SpoolAssignment).where(
|
||||
SpoolAssignment.printer_id == printer_id,
|
||||
SpoolAssignment.ams_id == ams_id,
|
||||
SpoolAssignment.tray_id == tray_id,
|
||||
)
|
||||
)
|
||||
assignment = assign_result.scalar_one_or_none()
|
||||
if not assignment:
|
||||
logger.info(
|
||||
"[UsageTracker] 3MF: no spool assignment at printer %d AMS%d-T%d", printer_id, ams_id, tray_id
|
||||
)
|
||||
continue
|
||||
spool_id = assignment.spool_id
|
||||
logger.info("[UsageTracker] 3MF: no spool assignment at printer %d AMS%d-T%d", printer_id, ams_id, tray_id)
|
||||
continue
|
||||
|
||||
# Load spool
|
||||
spool_result = await db.execute(select(Spool).where(Spool.id == spool_id))
|
||||
|
||||
@@ -427,6 +427,7 @@ def notification_provider_factory(db_session):
|
||||
"on_print_failed": True,
|
||||
"on_print_stopped": True,
|
||||
"on_print_progress": False,
|
||||
"on_print_missing_spool_assignment": False,
|
||||
"on_printer_offline": False,
|
||||
"on_printer_error": False,
|
||||
"on_filament_low": False,
|
||||
|
||||
@@ -391,6 +391,42 @@ class TestNotificationsAPI:
|
||||
assert result["on_bed_cooled"] is False
|
||||
assert result["on_first_layer_complete"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_create_provider_with_missing_spool_assignment_toggle(self, async_client: AsyncClient):
|
||||
"""Verify missing spool assignment toggle persists on create."""
|
||||
data = {
|
||||
"name": "Missing Spool Assignment Test",
|
||||
"provider_type": "ntfy",
|
||||
"config": {"server": "https://ntfy.sh", "topic": "test"},
|
||||
"on_print_missing_spool_assignment": True,
|
||||
}
|
||||
|
||||
response = await async_client.post("/api/v1/notifications/", json=data)
|
||||
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["on_print_missing_spool_assignment"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_update_missing_spool_assignment_toggle(
|
||||
self, async_client: AsyncClient, notification_provider_factory, db_session
|
||||
):
|
||||
"""CRITICAL: Verify missing spool assignment toggle persists correctly."""
|
||||
provider = await notification_provider_factory(on_print_missing_spool_assignment=False)
|
||||
|
||||
response = await async_client.patch(
|
||||
f"/api/v1/notifications/{provider.id}",
|
||||
json={"on_print_missing_spool_assignment": True},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["on_print_missing_spool_assignment"] is True
|
||||
|
||||
response = await async_client.get(f"/api/v1/notifications/{provider.id}")
|
||||
assert response.json()["on_print_missing_spool_assignment"] is True
|
||||
|
||||
|
||||
class TestNotificationTemplatesAPI:
|
||||
"""Integration tests for /api/v1/notification-templates/ endpoints."""
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Unit tests for spool assignment notification service."""
|
||||
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.services.spool_assignment_notifications import notify_missing_spool_assignments_on_print_start
|
||||
|
||||
|
||||
class _FakeAssignmentsResult:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def fetchall(self):
|
||||
return self._rows
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self, printer_name: str, assignments: list[SimpleNamespace]):
|
||||
self._printer = SimpleNamespace(name=printer_name)
|
||||
self._assignments = assignments
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
async def get(self, model, key):
|
||||
return self._printer
|
||||
|
||||
async def execute(self, statement):
|
||||
return _FakeAssignmentsResult(self._assignments)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_assignment_broadcasts_websocket_event_and_push_notification():
|
||||
"""When a mapped tray is unassigned, service emits websocket and notification events."""
|
||||
logger = logging.getLogger(__name__)
|
||||
data = {
|
||||
"ams_mapping": [1],
|
||||
"raw_data": {},
|
||||
}
|
||||
|
||||
# Assignment exists for A1 (global tray 0), but print uses A2 (global tray 1).
|
||||
assignments = [SimpleNamespace(ams_id=0, tray_id=0)]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.app.services.spool_assignment_notifications.async_session",
|
||||
return_value=_FakeSession("Printer A", assignments),
|
||||
),
|
||||
patch("backend.app.services.spool_assignment_notifications.printer_manager.get_status", return_value=None),
|
||||
patch(
|
||||
"backend.app.services.spool_assignment_notifications.ws_manager.send_missing_spool_assignment",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_ws,
|
||||
patch(
|
||||
"backend.app.services.spool_assignment_notifications.notification_service.on_print_missing_spool_assignment",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_notify,
|
||||
):
|
||||
await notify_missing_spool_assignments_on_print_start(1, data, logger)
|
||||
|
||||
mock_ws.assert_awaited_once()
|
||||
ws_kwargs = mock_ws.await_args.kwargs
|
||||
assert ws_kwargs["printer_id"] == 1
|
||||
assert ws_kwargs["printer_name"] == "Printer A"
|
||||
assert ws_kwargs["missing_slots"] == [{"slot": "A2", "profile": "Unknown", "color": "Unknown"}]
|
||||
|
||||
mock_notify.assert_awaited_once()
|
||||
notify_kwargs = mock_notify.await_args.kwargs
|
||||
assert notify_kwargs["printer_id"] == 1
|
||||
assert notify_kwargs["printer_name"] == "Printer A"
|
||||
assert notify_kwargs["missing_slots"] == [{"slot": "A2", "profile": "Unknown", "color": "Unknown"}]
|
||||
@@ -32,13 +32,14 @@ def _make_spool(*, id=1, label_weight=1000, weight_used=0, tag_uid=None, tray_uu
|
||||
return spool
|
||||
|
||||
|
||||
def _make_assignment(*, spool_id=1, printer_id=1, ams_id=0, tray_id=0):
|
||||
def _make_assignment(*, spool_id=1, printer_id=1, ams_id=0, tray_id=0, created_at=None):
|
||||
"""Create a mock SpoolAssignment object."""
|
||||
assignment = MagicMock()
|
||||
assignment.spool_id = spool_id
|
||||
assignment.printer_id = printer_id
|
||||
assignment.ams_id = ams_id
|
||||
assignment.tray_id = tray_id
|
||||
assignment.created_at = created_at or datetime.now(timezone.utc)
|
||||
return assignment
|
||||
|
||||
|
||||
@@ -570,10 +571,11 @@ class TestSpoolAssignmentSnapshot:
|
||||
ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]
|
||||
pm = _make_printer_manager(_make_printer_state(ams_data))
|
||||
|
||||
# db only returns spool (NO assignment query)
|
||||
# db returns no live assignment, then spool from snapshot spool_id
|
||||
db = AsyncMock()
|
||||
db.execute = AsyncMock(
|
||||
side_effect=[
|
||||
MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
|
||||
MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
|
||||
]
|
||||
)
|
||||
@@ -657,13 +659,15 @@ class TestSpoolAssignmentSnapshot:
|
||||
|
||||
filament_usage = [{"slot_id": 1, "used_g": 14.2, "type": "PLA", "color": "#FF0000"}]
|
||||
|
||||
# db: archive, queue_item(None), spool, then cost aggregation queries
|
||||
# db: archive, queue_item(None), live assignment(None), spool,
|
||||
# then cost aggregation queries
|
||||
# NOTE: No assignment in db — it was deleted by on_ams_change mid-print!
|
||||
db = AsyncMock()
|
||||
db.execute = AsyncMock(
|
||||
side_effect=[
|
||||
MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
|
||||
MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
|
||||
MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
|
||||
MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
|
||||
# Cost aggregation: sum query (uses .scalar()), archive lookup
|
||||
MagicMock(scalar=MagicMock(return_value=0)),
|
||||
|
||||
@@ -5,7 +5,7 @@ AMS remain% delta as fallback, per-layer gcode for partial prints,
|
||||
slot-to-tray mapping resolution, and notification variable formatting.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -327,6 +327,107 @@ class TestOnPrintComplete:
|
||||
class TestTrackFrom3mf:
|
||||
"""Tests for _track_from_3mf() — per-layer, linear scaling, and slot mapping."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prefers_live_assignment_when_reassigned_mid_print(self):
|
||||
"""If tray assignment changed during print, track usage on the new spool."""
|
||||
spool_old = _make_spool(spool_id=1, label_weight=1000)
|
||||
spool_new = _make_spool(spool_id=2, label_weight=1000)
|
||||
archive = _make_archive(archive_id=80)
|
||||
|
||||
live_assignment = _make_assignment(spool_id=2, ams_id=0, tray_id=0)
|
||||
started_at = datetime.now(timezone.utc)
|
||||
live_assignment.created_at = started_at + timedelta(seconds=5)
|
||||
|
||||
# db: archive, queue_item(None), live assignment lookup, spool_new lookup
|
||||
db = _mock_db_sequential([archive, None, live_assignment, spool_new])
|
||||
|
||||
printer_manager = MagicMock()
|
||||
printer_manager.get_status.return_value = SimpleNamespace(
|
||||
progress=100,
|
||||
layer_num=50,
|
||||
tray_now=0,
|
||||
)
|
||||
|
||||
filament_usage = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": ""}]
|
||||
handled_trays: set[tuple[int, int]] = set()
|
||||
|
||||
with (
|
||||
patch("backend.app.core.config.settings") as mock_settings,
|
||||
patch(
|
||||
"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
|
||||
return_value=filament_usage,
|
||||
),
|
||||
):
|
||||
mock_settings.base_dir = MagicMock()
|
||||
mock_path = MagicMock()
|
||||
mock_path.exists.return_value = True
|
||||
mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
|
||||
|
||||
results = await _track_from_3mf(
|
||||
printer_id=1,
|
||||
archive_id=80,
|
||||
status="completed",
|
||||
print_name="MidPrintReassign",
|
||||
handled_trays=handled_trays,
|
||||
printer_manager=printer_manager,
|
||||
db=db,
|
||||
spool_assignments={(0, 0): spool_old.id},
|
||||
print_started_at=started_at,
|
||||
)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0]["spool_id"] == spool_new.id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keeps_snapshot_when_live_assignment_predates_print(self):
|
||||
"""If live assignment predates print start, preserve snapshot spool mapping."""
|
||||
spool_old = _make_spool(spool_id=1, label_weight=1000)
|
||||
archive = _make_archive(archive_id=81)
|
||||
|
||||
live_assignment = _make_assignment(spool_id=2, ams_id=0, tray_id=0)
|
||||
started_at = datetime.now(timezone.utc)
|
||||
live_assignment.created_at = started_at - timedelta(seconds=5)
|
||||
|
||||
# db: archive, queue_item(None), live assignment lookup, spool_old lookup
|
||||
db = _mock_db_sequential([archive, None, live_assignment, spool_old])
|
||||
|
||||
printer_manager = MagicMock()
|
||||
printer_manager.get_status.return_value = SimpleNamespace(
|
||||
progress=100,
|
||||
layer_num=50,
|
||||
tray_now=0,
|
||||
)
|
||||
|
||||
filament_usage = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": ""}]
|
||||
handled_trays: set[tuple[int, int]] = set()
|
||||
|
||||
with (
|
||||
patch("backend.app.core.config.settings") as mock_settings,
|
||||
patch(
|
||||
"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
|
||||
return_value=filament_usage,
|
||||
),
|
||||
):
|
||||
mock_settings.base_dir = MagicMock()
|
||||
mock_path = MagicMock()
|
||||
mock_path.exists.return_value = True
|
||||
mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
|
||||
|
||||
results = await _track_from_3mf(
|
||||
printer_id=1,
|
||||
archive_id=81,
|
||||
status="completed",
|
||||
print_name="SnapshotPreserved",
|
||||
handled_trays=handled_trays,
|
||||
printer_manager=printer_manager,
|
||||
db=db,
|
||||
spool_assignments={(0, 0): spool_old.id},
|
||||
print_started_at=started_at,
|
||||
)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0]["spool_id"] == spool_old.id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_linear_fallback_for_partial_print(self):
|
||||
"""Falls back to linear scaling when gcode layer data unavailable."""
|
||||
|
||||
Reference in New Issue
Block a user