mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-01 03:31:25 +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."""
|
||||
|
||||
@@ -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(), {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '進捗マイルストーン',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '进度里程碑',
|
||||
|
||||
Reference in New Issue
Block a user