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:
Keybored
2026-03-24 11:31:32 +01:00
committed by GitHub
parent dd75c8f0c9
commit 3f7d0e2d55
28 changed files with 726 additions and 70 deletions
+2
View File
@@ -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,
+8
View File
@@ -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(
+16
View File
@@ -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()
+6
View File
@@ -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)
+1
View File
@@ -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",
+5
View File
@@ -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)
+99 -47
View File
@@ -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))
+1
View File
@@ -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)),
+102 -1
View File
@@ -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."""
@@ -9,11 +9,26 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ToastProvider } from '../../contexts/ToastContext';
// Track WebSocket instances created during tests
let wsInstances: MockWebSocket[] = [];
let originalWebSocket: typeof WebSocket;
// Mock react-i18next BEFORE any modules that use it are imported
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (key === 'printers.toast.missingSpoolAssignment' && options) {
const { printer, slots } = options as { printer: string; slots: string };
return `Missing assignments for ${printer}: ${slots}`;
}
return key;
},
i18n: {},
}),
}));
// Enhanced MockWebSocket that tracks instances
class MockWebSocket {
static readonly CONNECTING = 0;
@@ -77,13 +92,17 @@ function createTestQueryClient() {
});
}
// Wrapper with QueryClient for hook testing
// Wrapper with QueryClient and ToastProvider for hook testing
function createWrapper(queryClient: QueryClient) {
return function Wrapper({ children }: { children: React.ReactNode }) {
return React.createElement(
QueryClientProvider,
{ client: queryClient },
children
ToastProvider,
{},
React.createElement(
QueryClientProvider,
{ client: queryClient },
children
)
);
};
}
@@ -99,6 +118,7 @@ describe('useWebSocket hook', () => {
vi.clearAllMocks();
wsInstances = [];
queryClient = createTestQueryClient();
// Save original and install mock
originalWebSocket = globalThis.WebSocket;
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
@@ -164,8 +184,6 @@ describe('useWebSocket hook', () => {
describe('hook connection', () => {
it('connects to WebSocket on mount', async () => {
// Reset module cache to get fresh import with our mock
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
renderHook(() => useWebSocket(), {
@@ -178,7 +196,6 @@ describe('useWebSocket hook', () => {
});
it('reports connected state when WebSocket opens', async () => {
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
const { result } = renderHook(() => useWebSocket(), {
@@ -260,7 +277,6 @@ describe('useWebSocket hook', () => {
cb(0);
return 0;
});
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
@@ -303,7 +319,6 @@ describe('useWebSocket hook', () => {
cb(0);
return 0;
});
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
@@ -345,7 +360,6 @@ describe('useWebSocket hook', () => {
cb(0);
return 0;
});
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
@@ -380,8 +394,39 @@ describe('useWebSocket hook', () => {
vi.unstubAllGlobals();
});
it('handles missing_spool_assignment message without error', async () => {
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0);
return 0;
});
const { useWebSocket } = await import('../../hooks/useWebSocket');
renderHook(() => useWebSocket(), {
wrapper: createWrapper(queryClient),
});
const ws = getLatestWs()!;
act(() => {
ws.open();
});
// This test verifies that the hook properly handles missing_spool_assignment messages
// without throwing an error. The actual toast display is tested via the UI.
expect(() => {
act(() => {
ws.simulateMessage({
type: 'missing_spool_assignment',
printer_id: 7,
printer_name: 'Printer B',
missing_slots: [{ slot: 'A2' }, { slot: 'Ext-L' }],
});
});
}).not.toThrow();
vi.unstubAllGlobals();
});
it('ignores pong messages without error', async () => {
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
@@ -409,7 +454,6 @@ describe('useWebSocket hook', () => {
});
it('handles malformed JSON gracefully', async () => {
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
renderHook(() => useWebSocket(), {
@@ -438,7 +482,6 @@ describe('useWebSocket hook', () => {
});
it('handles unknown message types gracefully', async () => {
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
@@ -470,7 +513,6 @@ describe('useWebSocket hook', () => {
describe('sendMessage', () => {
it('sends JSON message when connected', async () => {
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
const { result } = renderHook(() => useWebSocket(), {
@@ -494,7 +536,6 @@ describe('useWebSocket hook', () => {
});
it('does not send when disconnected', async () => {
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
const { result } = renderHook(() => useWebSocket(), {
@@ -516,7 +557,6 @@ describe('useWebSocket hook', () => {
describe('reconnection', () => {
it('reconnects after connection closes', async () => {
vi.useFakeTimers();
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
@@ -551,7 +591,6 @@ describe('useWebSocket hook', () => {
});
it('cleans up on unmount', async () => {
vi.resetModules();
const { useWebSocket } = await import('../../hooks/useWebSocket');
const { unmount } = renderHook(() => useWebSocket(), {
+3
View File
@@ -1469,6 +1469,7 @@ export interface NotificationProvider {
on_print_failed: boolean;
on_print_stopped: boolean;
on_print_progress: boolean;
on_print_missing_spool_assignment: boolean;
// Printer status events
on_printer_offline: boolean;
on_printer_error: boolean;
@@ -1523,6 +1524,7 @@ export interface NotificationProviderCreate {
on_print_failed?: boolean;
on_print_stopped?: boolean;
on_print_progress?: boolean;
on_print_missing_spool_assignment?: boolean;
// Printer status events
on_printer_offline?: boolean;
on_printer_error?: boolean;
@@ -1570,6 +1572,7 @@ export interface NotificationProviderUpdate {
on_print_failed?: boolean;
on_print_stopped?: boolean;
on_print_progress?: boolean;
on_print_missing_spool_assignment?: boolean;
// Printer status events
on_printer_offline?: boolean;
on_printer_error?: boolean;
@@ -161,6 +161,9 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
{provider.on_first_layer_complete && (
<span className="px-2 py-0.5 bg-emerald-600/20 text-emerald-300 text-xs rounded">{t('notifications.firstLayer')}</span>
)}
{provider.on_print_missing_spool_assignment && (
<span className="px-2 py-0.5 bg-amber-500/20 text-amber-300 text-xs rounded">{t('notifications.missingSpoolAssignmentLabel')}</span>
)}
{provider.quiet_hours_enabled && (
<span className="px-2 py-0.5 bg-indigo-500/20 text-indigo-400 text-xs rounded flex items-center gap-1">
<Moon className="w-3 h-3" />
@@ -296,6 +299,17 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">{t('notifications.missingSpoolAssignmentLabel')}</p>
<p className="text-xs text-bambu-gray">{t('notifications.missingSpoolAssignmentDescription')}</p>
</div>
<Toggle
checked={provider.on_print_missing_spool_assignment ?? false}
onChange={(checked) => updateMutation.mutate({ on_print_missing_spool_assignment: checked })}
/>
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-white">{t('notifications.printFailed')}</p>
<Toggle
+4 -1
View File
@@ -6,6 +6,8 @@ import { formatFileSize } from '../utils/file';
type ToastType = 'success' | 'error' | 'warning' | 'info' | 'loading';
type ShowPersistentToast = (id: string, message: string, type?: ToastType) => void;
interface Toast {
id: string;
message: string;
@@ -38,7 +40,7 @@ interface DispatchToastData {
interface ToastContextType {
showToast: (message: string, type?: ToastType) => void;
showPersistentToast: (id: string, message: string, type?: ToastType) => void;
showPersistentToast: ShowPersistentToast;
dismissToast: (id: string) => void;
}
@@ -460,6 +462,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
return () => window.removeEventListener('background-dispatch', onDispatchEvent);
}, [t]);
return (
<ToastContext.Provider value={{ showToast, showPersistentToast, dismissToast }}>
{children}
+37 -1
View File
@@ -1,10 +1,14 @@
import { useQueryClient } from '@tanstack/react-query';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useToast } from '../contexts/ToastContext';
import { useTranslation } from 'react-i18next';
interface WebSocketMessage {
type: string;
printer_id?: number;
data?: Record<string, unknown>;
printer_name?: string;
missing_slots?: Array<{ slot?: string }>;
}
export function useWebSocket() {
@@ -12,6 +16,9 @@ export function useWebSocket() {
const reconnectTimeoutRef = useRef<number | null>(null);
const queryClient = useQueryClient();
const [isConnected, setIsConnected] = useState(false);
const lastMissingSpoolWarningRef = useRef<Map<number, string>>(new Map());
const { showToast } = useToast();
const { t } = useTranslation();
// Debounce invalidations to prevent rapid re-render cascades
const pendingInvalidations = useRef<Set<string>>(new Set());
@@ -195,6 +202,35 @@ export function useWebSocket() {
}
break;
case 'missing_spool_assignment': {
if (message.printer_id === undefined || !Array.isArray(message.missing_slots)) {
break;
}
const missingSlotLabels = message.missing_slots
.map((slot) => (slot && typeof slot.slot === 'string' ? slot.slot : 'Unknown'))
.filter((slot) => slot.length > 0);
if (missingSlotLabels.length === 0) {
lastMissingSpoolWarningRef.current.delete(message.printer_id);
break;
}
const signature = missingSlotLabels.join('|');
if (lastMissingSpoolWarningRef.current.get(message.printer_id) === signature) {
break;
}
lastMissingSpoolWarningRef.current.set(message.printer_id, signature);
const printerName = message.printer_name || `Printer ${message.printer_id}`;
const toastMsg = t('printers.toast.missingSpoolAssignment', {
printer: printerName,
slots: missingSlotLabels.join(', '),
});
showToast(toastMsg, 'warning');
break;
}
case 'print_complete':
// Don't invalidate printerStatus here - it causes re-render cascade and browser freeze
// The printer_status websocket messages will naturally update the status
@@ -301,7 +337,7 @@ export function useWebSocket() {
debouncedInvalidate('spoolbuddy-update-check');
break;
}
}, [queryClient, debouncedInvalidate, throttledPrinterStatusUpdate]);
}, [queryClient, debouncedInvalidate, throttledPrinterStatusUpdate, showToast, t]);
// Keep the ref updated with latest handleMessage
useEffect(() => {
+3
View File
@@ -265,6 +265,7 @@ export default {
// Toast messages
toast: {
printerDeleted: 'Drucker gelöscht',
missingSpoolAssignment: 'Druck gestartet auf {{printer}}. Fehlende Spulenzuordnung für: {{slots}}',
printerAdded: 'Drucker hinzugefügt',
printerUpdated: 'Drucker aktualisiert',
failedToDelete: 'Drucker konnte nicht gelöscht werden',
@@ -3821,6 +3822,8 @@ export default {
bedCooledDescription: 'Bett nach dem Druck unter Schwellenwert abgekühlt',
firstLayerCompleteLabel: 'Erste Schicht fertig',
firstLayerCompleteDescription: 'Benachrichtigung mit Foto nach erster Schicht',
missingSpoolAssignmentLabel: 'Fehlende Spulenzuordnung',
missingSpoolAssignmentDescription: 'Benachrichtigen, wenn ein Druck startet und benoetigte Schaechte keine zugeordnete Spule haben',
printFailed: 'Druck fehlgeschlagen',
printStopped: 'Druck gestoppt',
progressMilestones: 'Fortschrittsmeilensteine',
+3
View File
@@ -265,6 +265,7 @@ export default {
// Toast messages
toast: {
printerDeleted: 'Printer deleted',
missingSpoolAssignment: 'Print started on {{printer}}. Missing spool assignment for: {{slots}}',
printerAdded: 'Printer added',
printerUpdated: 'Printer updated',
failedToDelete: 'Failed to delete printer',
@@ -3826,6 +3827,8 @@ export default {
bedCooledDescription: 'Bed cooled below threshold after print',
firstLayerCompleteLabel: 'First Layer Complete',
firstLayerCompleteDescription: 'Notify with snapshot when first layer finishes',
missingSpoolAssignmentLabel: 'Missing Spool Assignment',
missingSpoolAssignmentDescription: 'Notify when print starts and required trays have no assigned spool',
printFailed: 'Print Failed',
printStopped: 'Print Stopped',
progressMilestones: 'Progress Milestones',
+3
View File
@@ -265,6 +265,7 @@ export default {
// Toast messages
toast: {
printerDeleted: 'Imprimante supprimée',
missingSpoolAssignment: 'Impression démarrée sur {{printer}}. Attribution de bobine manquante pour : {{slots}}',
printerAdded: 'Imprimante ajoutée',
printerUpdated: 'Imprimante mise à jour',
failedToDelete: 'Échec de la suppression',
@@ -3813,6 +3814,8 @@ export default {
bedCooledDescription: 'Plateau refroidi sous le seuil après l\'impression',
firstLayerCompleteLabel: 'Première couche terminée',
firstLayerCompleteDescription: 'Notification avec photo après la première couche',
missingSpoolAssignmentLabel: 'Affectation de bobine manquante',
missingSpoolAssignmentDescription: 'Notifier quand une impression démarre et que des bacs requis n\'ont pas de bobine assignée',
printFailed: 'Impression échouée',
printStopped: 'Impression arrêtée',
progressMilestones: 'Jalons de progression',
+3
View File
@@ -265,6 +265,7 @@ export default {
// Toast messages
toast: {
printerDeleted: 'Stampante eliminata',
missingSpoolAssignment: 'Stampa avviata su {{printer}}. Mancano assegnazioni bobina per: {{slots}}',
printerAdded: 'Stampante aggiunta',
printerUpdated: 'Stampante aggiornata',
failedToDelete: 'Impossibile eliminare stampante',
@@ -3812,6 +3813,8 @@ export default {
bedCooledDescription: 'Piatto raffreddato sotto la soglia dopo la stampa',
firstLayerCompleteLabel: 'Primo strato completato',
firstLayerCompleteDescription: 'Notifica con foto al termine del primo strato',
missingSpoolAssignmentLabel: 'Assegnazione bobina mancante',
missingSpoolAssignmentDescription: 'Notifica quando una stampa parte e i vassoi richiesti non hanno una bobina assegnata',
printFailed: 'Stampa fallita',
printStopped: 'Stampa interrotta',
progressMilestones: 'Traguardi di avanzamento',
+3
View File
@@ -264,6 +264,7 @@ export default {
// Toast messages
toast: {
printerDeleted: 'プリンターを削除しました',
missingSpoolAssignment: '{{printer}}で印刷を開始しました。以下のスプール割り当てがありません: {{slots}}',
printerAdded: 'プリンターを追加しました',
printerUpdated: 'プリンターを更新しました',
failedToDelete: 'プリンターの削除に失敗しました',
@@ -3825,6 +3826,8 @@ export default {
bedCooledDescription: '印刷後にベッドがしきい値以下に冷却',
firstLayerCompleteLabel: '第1層完了',
firstLayerCompleteDescription: '第1層完了時にスナップショット付きで通知',
missingSpoolAssignmentLabel: 'スプール割り当て不足',
missingSpoolAssignmentDescription: '印刷開始時に必要トレイへスプールが未割り当ての場合に通知',
printFailed: '印刷失敗',
printStopped: '印刷停止',
progressMilestones: '進捗マイルストーン',
+3
View File
@@ -265,6 +265,7 @@ export default {
// Toast messages
toast: {
printerDeleted: 'Impressora excluída',
missingSpoolAssignment: 'Impressão iniciada em {{printer}}. Atribuição de bobina ausente para: {{slots}}',
printerAdded: 'Impressora adicionada',
printerUpdated: 'Impressora atualizada',
failedToDelete: 'Falha ao excluir impressora',
@@ -3812,6 +3813,8 @@ export default {
bedCooledDescription: 'Mesa resfriou abaixo do limite após a impressão',
firstLayerCompleteLabel: 'Primeira camada concluída',
firstLayerCompleteDescription: 'Notificar com foto quando a primeira camada terminar',
missingSpoolAssignmentLabel: 'Atribuição de bobina ausente',
missingSpoolAssignmentDescription: 'Notificar quando a impressão iniciar e bandejas necessárias não tiverem bobina atribuída',
printFailed: 'Impressão Falhou',
printStopped: 'Impressão Parada',
progressMilestones: 'Marcos de Progresso',
+3
View File
@@ -265,6 +265,7 @@ export default {
// Toast messages
toast: {
printerDeleted: '打印机已删除',
missingSpoolAssignment: '已在{{printer}}上开始打印。以下料槽未分配耗材: {{slots}}',
printerAdded: '打印机已添加',
printerUpdated: '打印机已更新',
failedToDelete: '删除打印机失败',
@@ -3812,6 +3813,8 @@ export default {
bedCooledDescription: '打印后热床温度降至阈值以下',
firstLayerCompleteLabel: '首层打印完成',
firstLayerCompleteDescription: '首层完成时发送带照片的通知',
missingSpoolAssignmentLabel: '缺少料卷分配',
missingSpoolAssignmentDescription: '当打印开始且所需料盘没有分配料卷时发送通知',
printFailed: '打印失败',
printStopped: '打印已停止',
progressMilestones: '进度里程碑',