mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-19 07:33:18 +02:00
Merge branch 'dev' into feature/billing
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -2457,6 +2457,14 @@ async def create_spool_from_slot(
|
||||
if not tray or not tray.get("tray_type"):
|
||||
raise HTTPException(status_code=400, detail="Slot is empty or has no readable tray data")
|
||||
|
||||
# Guard against ghost-spool creation: a slot without any RFID tag has no
|
||||
# stable identity, so creating an inventory row would just duplicate on
|
||||
# every confirm and never re-link to the physical spool.
|
||||
from backend.app.services.spool_tag_matcher import is_valid_tag
|
||||
|
||||
if not is_valid_tag(tray.get("tag_uid", ""), tray.get("tray_uuid", "")):
|
||||
raise HTTPException(status_code=400, detail="Slot has no RFID tag")
|
||||
|
||||
spool = await create_spool_from_tray(db, tray)
|
||||
await auto_assign_spool(
|
||||
req.printer_id,
|
||||
|
||||
@@ -1115,6 +1115,64 @@ async def reorder_queue(
|
||||
return {"message": f"Reordered {len(data.items)} items"}
|
||||
|
||||
|
||||
@router.post("/printer/{printer_id}/resume")
|
||||
async def resume_queue_after_failure(
|
||||
printer_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_ALL),
|
||||
):
|
||||
"""Clear the previous-success gate for a printer and restore skipped items.
|
||||
|
||||
Single atomic op (#1818):
|
||||
|
||||
* Sets ``gate_acknowledged=True`` on every ``failed`` / ``aborted`` queue
|
||||
item for this printer that's still in the scheduler's lookback window,
|
||||
so the next ``_check_previous_success`` call ignores them.
|
||||
* Restores ``skipped`` items whose ``error_message`` matches the
|
||||
scheduler's exact "Previous print failed or was aborted" gate string
|
||||
back to ``pending`` (clears ``error_message`` + ``completed_at``).
|
||||
|
||||
Returns counts so the UI can render a precise toast. No-op endpoint
|
||||
(zero counts) when called against a printer with no gate to clear.
|
||||
"""
|
||||
result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
||||
printer = result.scalar_one_or_none()
|
||||
if not printer:
|
||||
raise HTTPException(404, "Printer not found")
|
||||
|
||||
ack_result = await db.execute(
|
||||
select(PrintQueueItem)
|
||||
.where(PrintQueueItem.printer_id == printer_id)
|
||||
.where(PrintQueueItem.status.in_(["failed", "aborted"]))
|
||||
.where(PrintQueueItem.gate_acknowledged == False) # noqa: E712
|
||||
)
|
||||
to_ack = ack_result.scalars().all()
|
||||
for failed_item in to_ack:
|
||||
failed_item.gate_acknowledged = True
|
||||
|
||||
restore_result = await db.execute(
|
||||
select(PrintQueueItem)
|
||||
.where(PrintQueueItem.printer_id == printer_id)
|
||||
.where(PrintQueueItem.status == "skipped")
|
||||
.where(PrintQueueItem.error_message == "Previous print failed or was aborted")
|
||||
)
|
||||
to_restore = restore_result.scalars().all()
|
||||
for skipped_item in to_restore:
|
||||
skipped_item.status = "pending"
|
||||
skipped_item.error_message = None
|
||||
skipped_item.completed_at = None
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
"Resume after failure on printer %s: acknowledged %d failure(s), restored %d skipped item(s)",
|
||||
printer_id,
|
||||
len(to_ack),
|
||||
len(to_restore),
|
||||
)
|
||||
return {"acknowledged": len(to_ack), "restored": len(to_restore)}
|
||||
|
||||
|
||||
@router.post("/{item_id}/cancel")
|
||||
async def cancel_queue_item(
|
||||
item_id: int,
|
||||
|
||||
@@ -1207,6 +1207,13 @@ async def create_spool_from_slot(
|
||||
if not tray:
|
||||
raise HTTPException(status_code=400, detail="Slot is empty or has no readable tray data")
|
||||
|
||||
# Same ghost-spool guard as the inventory route: no tag → no stable
|
||||
# identity → confirm would just create a fresh Spoolman row per push.
|
||||
from backend.app.services.spool_tag_matcher import is_valid_tag
|
||||
|
||||
if not is_valid_tag(tray.tag_uid or "", tray.tray_uuid or ""):
|
||||
raise HTTPException(status_code=400, detail="Slot has no RFID tag")
|
||||
|
||||
sync_result = await client.sync_ams_tray(
|
||||
tray,
|
||||
printer.name,
|
||||
|
||||
@@ -3221,6 +3221,15 @@ async def run_migrations(conn):
|
||||
"ALTER TABLE notification_providers ADD COLUMN on_ai_failure_detection BOOLEAN DEFAULT false",
|
||||
)
|
||||
|
||||
# Migration: Add gate_acknowledged column to print_queue (#1818). Cleared
|
||||
# by the per-printer "Resume after failure" action so the scheduler's
|
||||
# `_check_previous_success` lookback skips this row. Postgres rejects
|
||||
# `DEFAULT 0` for BOOLEAN columns.
|
||||
if is_sqlite():
|
||||
await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN gate_acknowledged BOOLEAN DEFAULT 0")
|
||||
else:
|
||||
await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN gate_acknowledged BOOLEAN DEFAULT false")
|
||||
|
||||
# Migration: Disambiguate the four ``user_print_*`` notification template
|
||||
# names by appending " Email" (#1792). See ``_migrate_rename_user_print_template_names``.
|
||||
await _migrate_rename_user_print_template_names(conn)
|
||||
|
||||
+3
-13
@@ -1916,19 +1916,9 @@ async def on_ams_change(printer_id: int, ams_data: list):
|
||||
tray_sub_brands=tray.get("tray_sub_brands"),
|
||||
tray_count=len(ams_unit.get("tray", [])),
|
||||
)
|
||||
else:
|
||||
# No tag at all — let user choose from inventory
|
||||
await _broadcast_unknown_tag(
|
||||
printer_id=printer_id,
|
||||
ams_id=ams_id,
|
||||
tray_id=tray_id,
|
||||
tag_uid="",
|
||||
tray_uuid="",
|
||||
tray_type=tray.get("tray_type"),
|
||||
tray_color=tray.get("tray_color"),
|
||||
tray_sub_brands=tray.get("tray_sub_brands"),
|
||||
tray_count=len(ams_unit.get("tray", [])),
|
||||
)
|
||||
# No-tag slots (generic non-RFID filament) are left alone:
|
||||
# nothing to identify, prompting "+ Add" would just create
|
||||
# ghost spools with empty tags on every confirm.
|
||||
except Exception as e:
|
||||
logger.warning("RFID spool auto-assign failed: %s", e, exc_info=True)
|
||||
|
||||
|
||||
@@ -94,6 +94,15 @@ class PrintQueueItem(Base):
|
||||
# Status: pending, printing, completed, failed, skipped, cancelled
|
||||
status: Mapped[str] = mapped_column(String(20), default="pending")
|
||||
|
||||
# Cleared by the per-printer "Resume after failure" action (#1818) so the
|
||||
# scheduler's `_check_previous_success` lookback skips this row. Without
|
||||
# this, a single `failed` or `aborted` print poisoned every later
|
||||
# `require_previous_success` item on the same printer forever — the
|
||||
# lookback excluded `skipped` but had no way to dismiss the originating
|
||||
# failure. The flag is per-item, not per-printer, so a fresh failure
|
||||
# after a resume re-gates downstream items independently.
|
||||
gate_acknowledged: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# Set by the dispatch scheduler when the assigned spool can't satisfy
|
||||
# this print's per-slot filament weight (#1496). Display-only flag — the
|
||||
# actual deficit is recomputed live every time the user clicks ▶, so
|
||||
|
||||
@@ -1766,35 +1766,55 @@ class BambuMQTTClient:
|
||||
self.state.tray_now = parsed_tray_now
|
||||
elif not self._is_dual_nozzle and 0 <= parsed_tray_now <= 3:
|
||||
# Single-nozzle printer with tray_now in 0-3 range.
|
||||
# P2S (and possibly other models) with multiple AMS units sends LOCAL slot IDs
|
||||
# in tray_now, not global tray IDs (#420). Use the MQTT mapping field
|
||||
# (snow-encoded) to resolve the correct AMS unit.
|
||||
ams_exist_raw = ams_data.get("ams_exist_bits", "0")
|
||||
try:
|
||||
ams_exist = int(ams_exist_raw, 16) if isinstance(ams_exist_raw, str) else int(ams_exist_raw)
|
||||
except (ValueError, TypeError):
|
||||
ams_exist = 0
|
||||
num_ams = bin(ams_exist).count("1")
|
||||
|
||||
if num_ams > 1:
|
||||
# Multiple AMS on single-nozzle — tray_now is likely a local slot ID.
|
||||
# Cross-reference with MQTT mapping field to find the correct AMS unit.
|
||||
mapping_raw = self.state.raw_data.get("mapping")
|
||||
resolved = self._resolve_local_slot_from_mapping(parsed_tray_now, mapping_raw)
|
||||
if resolved is not None:
|
||||
if resolved != parsed_tray_now:
|
||||
logger.debug(
|
||||
f"[{self.serial_number}] Multi-AMS tray_now: "
|
||||
f"local slot {parsed_tray_now} -> global ID {resolved} (from mapping)"
|
||||
)
|
||||
self.state.tray_now = resolved
|
||||
else:
|
||||
# No mapping available (not printing, or ambiguous) — use as-is.
|
||||
# This matches the old behavior and is correct for AMS 0.
|
||||
self.state.tray_now = parsed_tray_now
|
||||
# #1822: H2S firmware reports tray_now as the AMS's idle
|
||||
# slot (typically 0) when the active feed is actually the
|
||||
# external spool. X1C / P1S / A1 correctly report 254 in
|
||||
# that case; H2S does not. When the slicer-captured
|
||||
# ams_mapping is all-external (every entry == -1), the
|
||||
# print can only be feeding from the external spool, so
|
||||
# promote tray_now to 254. Mixed (e.g. [5, -1]) and
|
||||
# AMS-only mappings are NOT overridden — there's no
|
||||
# evidence the firmware misreports in those cases. Prints
|
||||
# started without a captured mapping (printer-screen start,
|
||||
# or before Bambuddy connected) fall through unchanged.
|
||||
captured = self._captured_ams_mapping
|
||||
if captured and all(s == -1 for s in captured):
|
||||
if self.state.tray_now != 254:
|
||||
logger.debug(
|
||||
f"[{self.serial_number}] tray_now external-spool override (#1822): "
|
||||
f"slot {parsed_tray_now} -> 254 (ams_mapping={captured})"
|
||||
)
|
||||
self.state.tray_now = 254
|
||||
else:
|
||||
# Single AMS — local slot 0-3 equals global ID
|
||||
self.state.tray_now = parsed_tray_now
|
||||
# P2S (and possibly other models) with multiple AMS units sends LOCAL slot IDs
|
||||
# in tray_now, not global tray IDs (#420). Use the MQTT mapping field
|
||||
# (snow-encoded) to resolve the correct AMS unit.
|
||||
ams_exist_raw = ams_data.get("ams_exist_bits", "0")
|
||||
try:
|
||||
ams_exist = int(ams_exist_raw, 16) if isinstance(ams_exist_raw, str) else int(ams_exist_raw)
|
||||
except (ValueError, TypeError):
|
||||
ams_exist = 0
|
||||
num_ams = bin(ams_exist).count("1")
|
||||
|
||||
if num_ams > 1:
|
||||
# Multiple AMS on single-nozzle — tray_now is likely a local slot ID.
|
||||
# Cross-reference with MQTT mapping field to find the correct AMS unit.
|
||||
mapping_raw = self.state.raw_data.get("mapping")
|
||||
resolved = self._resolve_local_slot_from_mapping(parsed_tray_now, mapping_raw)
|
||||
if resolved is not None:
|
||||
if resolved != parsed_tray_now:
|
||||
logger.debug(
|
||||
f"[{self.serial_number}] Multi-AMS tray_now: "
|
||||
f"local slot {parsed_tray_now} -> global ID {resolved} (from mapping)"
|
||||
)
|
||||
self.state.tray_now = resolved
|
||||
else:
|
||||
# No mapping available (not printing, or ambiguous) — use as-is.
|
||||
# This matches the old behavior and is correct for AMS 0.
|
||||
self.state.tray_now = parsed_tray_now
|
||||
else:
|
||||
# Single AMS — local slot 0-3 equals global ID
|
||||
self.state.tray_now = parsed_tray_now
|
||||
else:
|
||||
# tray_now > 3 means it's already a global ID, or 255 means unloaded
|
||||
# Note: Do NOT clear pending_tray_target on tray_now=255 here.
|
||||
|
||||
@@ -1905,12 +1905,18 @@ class PrintScheduler:
|
||||
items — counting it as a failed predecessor was the cascade bug that
|
||||
let a single cancellation block 18 items over 3 days for the reporter.
|
||||
Only `failed` and `aborted` — real print-attempt failures — block.
|
||||
|
||||
Failures with `gate_acknowledged=True` (set by the per-printer Resume
|
||||
action — #1818) are also excluded from the lookback so the user can
|
||||
clear the gate after fixing the physical issue without having to
|
||||
re-queue every downstream job.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(PrintQueueItem)
|
||||
.where(PrintQueueItem.printer_id == item.printer_id)
|
||||
.where(PrintQueueItem.id != item.id)
|
||||
.where(PrintQueueItem.status.in_(["completed", "failed", "cancelled", "aborted"]))
|
||||
.where(PrintQueueItem.gate_acknowledged == False) # noqa: E712
|
||||
.order_by(PrintQueueItem.completed_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
@@ -200,6 +200,19 @@ async def resolve_slicer_filament(
|
||||
or tray_info_idx.startswith("PFCN")
|
||||
):
|
||||
tray_info_idx = ""
|
||||
setting_id = ""
|
||||
# Preserve setting_id when it's still a valid slicer reference
|
||||
# (PFUS / PFCN cloud user/shared preset, or GFS Bambu official
|
||||
# preset). The slicer accepts these as setting_id even though
|
||||
# they're rejected as tray_info_idx; without preservation the
|
||||
# slicer falls back to whatever generic filament the caller's
|
||||
# tray_info_idx fallback produces and shows "Generic <Material>"
|
||||
# instead of the user's actual custom preset (#1815). Material-name
|
||||
# leaks (e.g. setting_id="PETG") are still cleared — those are
|
||||
# never valid slicer references.
|
||||
if not (
|
||||
setting_id
|
||||
and (setting_id.startswith("PFUS") or setting_id.startswith("PFCN") or setting_id.startswith("GFS"))
|
||||
):
|
||||
setting_id = ""
|
||||
|
||||
return (tray_info_idx, setting_id, sub_brand_override)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""No-tag guard on POST /api/v1/inventory/spools/from-slot.
|
||||
|
||||
A slot that has tray_type set but no RFID tag (generic third-party PLA the
|
||||
user manually configured on the printer panel) has no stable identity, so
|
||||
auto-creating an inventory row for it would just duplicate on every confirm
|
||||
without ever re-linking to the physical spool. Verify the endpoint refuses
|
||||
that case with 400.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
def _mock_status_for_tray(ams_id: int, tray_id: int, tray: dict) -> MagicMock:
|
||||
status = MagicMock()
|
||||
status.raw_data = {"ams": {"ams": [{"id": ams_id, "tray": [{"id": tray_id, **tray}]}]}}
|
||||
return status
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_from_slot_rejects_no_tag(async_client: AsyncClient, printer_factory):
|
||||
printer = await printer_factory(name="X1C-no-tag")
|
||||
|
||||
no_tag_tray = {
|
||||
"tray_type": "PLA",
|
||||
"tray_color": "FF0000FF",
|
||||
"tag_uid": "0000000000000000",
|
||||
"tray_uuid": "00000000000000000000000000000000",
|
||||
}
|
||||
|
||||
with patch(
|
||||
"backend.app.services.printer_manager.printer_manager.get_status",
|
||||
return_value=_mock_status_for_tray(0, 1, no_tag_tray),
|
||||
):
|
||||
resp = await async_client.post(
|
||||
"/api/v1/inventory/spools/from-slot",
|
||||
json={"printer_id": printer.id, "ams_id": 0, "tray_id": 1},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert "RFID tag" in resp.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_from_slot_rejects_empty_tag_strings(async_client: AsyncClient, printer_factory):
|
||||
"""Empty strings (not zero-filled) must also be refused."""
|
||||
printer = await printer_factory(name="X1C-empty-tag")
|
||||
|
||||
empty_tag_tray = {
|
||||
"tray_type": "PETG",
|
||||
"tray_color": "00FF00FF",
|
||||
"tag_uid": "",
|
||||
"tray_uuid": "",
|
||||
}
|
||||
|
||||
with patch(
|
||||
"backend.app.services.printer_manager.printer_manager.get_status",
|
||||
return_value=_mock_status_for_tray(0, 2, empty_tag_tray),
|
||||
):
|
||||
resp = await async_client.post(
|
||||
"/api/v1/inventory/spools/from-slot",
|
||||
json={"printer_id": printer.id, "ams_id": 0, "tray_id": 2},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
@@ -2544,3 +2544,247 @@ class TestAbortedStatusNormalisation:
|
||||
assert row["archive_deleted"] is False
|
||||
assert row["archive_name"] == "Live Archive"
|
||||
assert row["archive_thumbnail"] == "archives/test/live/thumbnail.png"
|
||||
|
||||
|
||||
class TestResumeQueueAfterFailure:
|
||||
"""Integration tests for POST /api/v1/queue/printer/{id}/resume (#1818)."""
|
||||
|
||||
@pytest.fixture
|
||||
async def printer_factory(self, db_session):
|
||||
_counter = [0]
|
||||
|
||||
async def _create_printer(**kwargs):
|
||||
from backend.app.models.printer import Printer
|
||||
|
||||
_counter[0] += 1
|
||||
counter = _counter[0]
|
||||
defaults = {
|
||||
"name": f"Resume Printer {counter}",
|
||||
"ip_address": f"192.168.42.{100 + counter}",
|
||||
"serial_number": f"RESUMESERIAL{counter:04d}",
|
||||
"access_code": "12345678",
|
||||
"model": "P1S",
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
printer = Printer(**defaults)
|
||||
db_session.add(printer)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(printer)
|
||||
return printer
|
||||
|
||||
return _create_printer
|
||||
|
||||
@pytest.fixture
|
||||
async def archive_factory(self, db_session):
|
||||
_counter = [0]
|
||||
|
||||
async def _create_archive(**kwargs):
|
||||
from backend.app.models.archive import PrintArchive
|
||||
|
||||
_counter[0] += 1
|
||||
counter = _counter[0]
|
||||
defaults = {
|
||||
"filename": f"resume_print_{counter}.3mf",
|
||||
"print_name": f"Resume Print {counter}",
|
||||
"file_path": f"/tmp/resume_print_{counter}.3mf",
|
||||
"file_size": 1024,
|
||||
"content_hash": f"resumehash{counter:08d}",
|
||||
"status": "completed",
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
archive = PrintArchive(**defaults)
|
||||
db_session.add(archive)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(archive)
|
||||
return archive
|
||||
|
||||
return _create_archive
|
||||
|
||||
async def _add_item(self, db_session, printer, archive_factory, **kwargs):
|
||||
from backend.app.models.print_queue import PrintQueueItem
|
||||
|
||||
archive = await archive_factory()
|
||||
defaults = {
|
||||
"printer_id": printer.id,
|
||||
"archive_id": archive.id,
|
||||
"status": "pending",
|
||||
"require_previous_success": True,
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
item = PrintQueueItem(**defaults)
|
||||
db_session.add(item)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(item)
|
||||
return item
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_resume_unknown_printer_returns_404(self, async_client: AsyncClient):
|
||||
resp = await async_client.post("/api/v1/queue/printer/999999/resume")
|
||||
assert resp.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_resume_no_op_on_clean_queue(self, async_client: AsyncClient, printer_factory):
|
||||
"""Calling resume on a printer with no failures and no skipped items
|
||||
returns zero counts — endpoint is idempotent and safe to spam."""
|
||||
printer = await printer_factory()
|
||||
resp = await async_client.post(f"/api/v1/queue/printer/{printer.id}/resume")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"acknowledged": 0, "restored": 0}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_resume_acknowledges_failed_and_restores_skipped(
|
||||
self, async_client: AsyncClient, printer_factory, archive_factory, db_session
|
||||
):
|
||||
"""Reporter's scenario: failed predecessor + N skipped downstream items.
|
||||
Resume sets gate_acknowledged on the failure and flips skipped → pending."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.models.print_queue import PrintQueueItem
|
||||
|
||||
printer = await printer_factory()
|
||||
failed = await self._add_item(db_session, printer, archive_factory, status="failed")
|
||||
skipped_1 = await self._add_item(
|
||||
db_session,
|
||||
printer,
|
||||
archive_factory,
|
||||
status="skipped",
|
||||
error_message="Previous print failed or was aborted",
|
||||
)
|
||||
skipped_2 = await self._add_item(
|
||||
db_session,
|
||||
printer,
|
||||
archive_factory,
|
||||
status="skipped",
|
||||
error_message="Previous print failed or was aborted",
|
||||
)
|
||||
|
||||
resp = await async_client.post(f"/api/v1/queue/printer/{printer.id}/resume")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"acknowledged": 1, "restored": 2}
|
||||
|
||||
failed_id = failed.id
|
||||
skipped_ids = [skipped_1.id, skipped_2.id]
|
||||
db_session.expire_all()
|
||||
|
||||
result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == failed_id))
|
||||
assert result.scalar_one().gate_acknowledged is True
|
||||
|
||||
for sid in skipped_ids:
|
||||
result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == sid))
|
||||
row = result.scalar_one()
|
||||
assert row.status == "pending"
|
||||
assert row.error_message is None
|
||||
assert row.completed_at is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_resume_preserves_skipped_items_with_other_reasons(
|
||||
self, async_client: AsyncClient, printer_factory, archive_factory, db_session
|
||||
):
|
||||
"""Skipped items whose error_message is something OTHER than the
|
||||
gate string (e.g. filament-deficit promotion, future skip reasons)
|
||||
must not be touched — they encode different user intent."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.models.print_queue import PrintQueueItem
|
||||
|
||||
printer = await printer_factory()
|
||||
gate_skip = await self._add_item(
|
||||
db_session,
|
||||
printer,
|
||||
archive_factory,
|
||||
status="skipped",
|
||||
error_message="Previous print failed or was aborted",
|
||||
)
|
||||
other_skip = await self._add_item(
|
||||
db_session,
|
||||
printer,
|
||||
archive_factory,
|
||||
status="skipped",
|
||||
error_message="User skipped via UI",
|
||||
)
|
||||
|
||||
gate_id = gate_skip.id
|
||||
other_id = other_skip.id
|
||||
resp = await async_client.post(f"/api/v1/queue/printer/{printer.id}/resume")
|
||||
assert resp.json() == {"acknowledged": 0, "restored": 1}
|
||||
|
||||
db_session.expire_all()
|
||||
result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == gate_id))
|
||||
assert result.scalar_one().status == "pending"
|
||||
result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == other_id))
|
||||
assert result.scalar_one().status == "skipped"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_resume_scoped_to_printer(
|
||||
self, async_client: AsyncClient, printer_factory, archive_factory, db_session
|
||||
):
|
||||
"""A resume on printer A must not clear printer B's gate — farms run
|
||||
each printer's queue independently."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.models.print_queue import PrintQueueItem
|
||||
|
||||
p1 = await printer_factory()
|
||||
p2 = await printer_factory()
|
||||
failed_p1 = await self._add_item(db_session, p1, archive_factory, status="failed")
|
||||
failed_p2 = await self._add_item(db_session, p2, archive_factory, status="failed")
|
||||
|
||||
failed_p1_id = failed_p1.id
|
||||
failed_p2_id = failed_p2.id
|
||||
resp = await async_client.post(f"/api/v1/queue/printer/{p1.id}/resume")
|
||||
assert resp.json() == {"acknowledged": 1, "restored": 0}
|
||||
|
||||
db_session.expire_all()
|
||||
result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == failed_p1_id))
|
||||
assert result.scalar_one().gate_acknowledged is True
|
||||
result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == failed_p2_id))
|
||||
assert result.scalar_one().gate_acknowledged is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_resume_handles_aborted_status(
|
||||
self, async_client: AsyncClient, printer_factory, archive_factory, db_session
|
||||
):
|
||||
"""Aborted prints (printer-detected mid-print failure) gate the same
|
||||
way failed prints do and must also be acknowledgeable."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.models.print_queue import PrintQueueItem
|
||||
|
||||
printer = await printer_factory()
|
||||
aborted = await self._add_item(db_session, printer, archive_factory, status="aborted")
|
||||
aborted_id = aborted.id
|
||||
resp = await async_client.post(f"/api/v1/queue/printer/{printer.id}/resume")
|
||||
assert resp.json() == {"acknowledged": 1, "restored": 0}
|
||||
|
||||
db_session.expire_all()
|
||||
result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == aborted_id))
|
||||
assert result.scalar_one().gate_acknowledged is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_resume_idempotent_second_call_is_no_op(
|
||||
self, async_client: AsyncClient, printer_factory, archive_factory, db_session
|
||||
):
|
||||
"""Calling resume twice on the same printer doesn't re-acknowledge
|
||||
the same failure — the second call sees acknowledged=0, restored=0."""
|
||||
printer = await printer_factory()
|
||||
await self._add_item(db_session, printer, archive_factory, status="failed")
|
||||
await self._add_item(
|
||||
db_session,
|
||||
printer,
|
||||
archive_factory,
|
||||
status="skipped",
|
||||
error_message="Previous print failed or was aborted",
|
||||
)
|
||||
|
||||
first = await async_client.post(f"/api/v1/queue/printer/{printer.id}/resume")
|
||||
assert first.json() == {"acknowledged": 1, "restored": 1}
|
||||
|
||||
second = await async_client.post(f"/api/v1/queue/printer/{printer.id}/resume")
|
||||
assert second.json() == {"acknowledged": 0, "restored": 0}
|
||||
|
||||
@@ -5884,3 +5884,87 @@ class TestAmsFilamentBackupHoldTimer:
|
||||
assert mqtt_client.state.ams_filament_backup is True
|
||||
# Hold timer still armed — sub-second push didn't reset it.
|
||||
assert mqtt_client._xcam_hold_start["print_option_auto_switch_filament"] == before_hold
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2c. Single-nozzle H2S — external-spool tray_now override (#1822)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTrayNowH2SExternalSpoolOverride:
|
||||
"""H2S firmware reports tray_now as the AMS's idle slot (typically 0)
|
||||
instead of 254 when the active feed is the external spool.
|
||||
|
||||
Bambuddy detects the all-external case via the slicer-captured
|
||||
ams_mapping (every entry == -1) and promotes tray_now to 254 so the
|
||||
UI active-tray highlight matches the real feed.
|
||||
|
||||
The override is intentionally narrow:
|
||||
* only fires when ams_mapping is captured AND every entry is -1
|
||||
* does not touch mixed prints ([5, -1]) or AMS-only prints ([5])
|
||||
* does not fire when no ams_mapping is captured (printer-screen start)
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def mqtt_client(self):
|
||||
from backend.app.services.bambu_mqtt import BambuMQTTClient
|
||||
|
||||
return BambuMQTTClient(
|
||||
ip_address="192.168.1.100",
|
||||
serial_number="TEST_H2S",
|
||||
access_code="12345678",
|
||||
)
|
||||
|
||||
def test_all_external_mapping_promotes_tray_now_to_254(self, mqtt_client):
|
||||
"""Reporter's scenario: H2S, single nozzle, captured ams_mapping=[-1],
|
||||
firmware sends tray_now=0 -> Bambuddy promotes to 254."""
|
||||
mqtt_client._captured_ams_mapping = [-1]
|
||||
mqtt_client._process_message(_ams_payload(0))
|
||||
assert mqtt_client.state.tray_now == 254
|
||||
|
||||
def test_multi_external_mapping_also_promotes(self, mqtt_client):
|
||||
"""Multi-filament print, every filament mapped to external. Still
|
||||
all-external -> still promotes."""
|
||||
mqtt_client._captured_ams_mapping = [-1, -1, -1]
|
||||
mqtt_client._process_message(_ams_payload(0))
|
||||
assert mqtt_client.state.tray_now == 254
|
||||
|
||||
def test_ams_only_mapping_does_not_override(self, mqtt_client):
|
||||
"""ams_mapping=[5] (AMS slot 5 only) -> firmware value trusted as-is.
|
||||
Without the all-external guard, this would falsely override real
|
||||
AMS-slot prints."""
|
||||
mqtt_client._captured_ams_mapping = [5]
|
||||
mqtt_client._process_message(_ams_payload(0))
|
||||
assert mqtt_client.state.tray_now == 0
|
||||
|
||||
def test_mixed_mapping_does_not_override(self, mqtt_client):
|
||||
"""Mixed mapping (AMS slot 5 + external): we have no evidence the
|
||||
firmware misreports mid-print swaps, so leave tray_now alone."""
|
||||
mqtt_client._captured_ams_mapping = [5, -1]
|
||||
mqtt_client._process_message(_ams_payload(0))
|
||||
assert mqtt_client.state.tray_now == 0
|
||||
|
||||
def test_no_captured_mapping_does_not_override(self, mqtt_client):
|
||||
"""Prints started from the printer screen (or before Bambuddy
|
||||
connected) have no captured ams_mapping. Behaviour unchanged from
|
||||
pre-#1822 — we accept the wrong value rather than guess."""
|
||||
mqtt_client._captured_ams_mapping = None
|
||||
mqtt_client._process_message(_ams_payload(0))
|
||||
assert mqtt_client.state.tray_now == 0
|
||||
|
||||
def test_empty_captured_mapping_does_not_override(self, mqtt_client):
|
||||
"""Empty list (defensive — should not happen in practice but
|
||||
all([]) returns True). Treat as no signal, not as all-external."""
|
||||
mqtt_client._captured_ams_mapping = []
|
||||
mqtt_client._process_message(_ams_payload(0))
|
||||
assert mqtt_client.state.tray_now == 0
|
||||
|
||||
def test_unloaded_after_print_still_resolves_correctly(self, mqtt_client):
|
||||
"""When the firmware unloads (tray_now=255), the override is skipped
|
||||
because the branch only fires for tray_now in 0-3."""
|
||||
mqtt_client._captured_ams_mapping = [-1]
|
||||
mqtt_client._process_message(_ams_payload(0))
|
||||
assert mqtt_client.state.tray_now == 254
|
||||
|
||||
mqtt_client._process_message(_ams_payload(255))
|
||||
assert mqtt_client.state.tray_now == 255
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Tests for ``resolve_slicer_filament`` (#1815).
|
||||
|
||||
The defensive filter at the end of the resolver clears ``tray_info_idx``
|
||||
when its value isn't slicer-acceptable (literal material names + PFUS /
|
||||
PFCN cloud-preset prefixes that the printer's calibration table can't
|
||||
key on). Pre-#1815 it cleared ``setting_id`` alongside, which dropped
|
||||
the slicer's only handle on the user's actual custom preset and forced
|
||||
the caller into the generic-material fallback — Bambu Studio then
|
||||
displayed "Generic <Material>" for spools whose Bambu Cloud detail
|
||||
lookup didn't resolve a ``filament_id`` (cloud unauth on the on_ams_change
|
||||
replay path, transient cloud failure, or custom presets whose detail
|
||||
JSON omits ``filament_id``).
|
||||
|
||||
Post-#1815 the filter preserves a setting_id that's still a valid
|
||||
slicer reference (PFUS / PFCN cloud user/shared preset, or GFS Bambu
|
||||
official preset) even when ``tray_info_idx`` is cleared.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.services.slicer_filament_resolver import resolve_slicer_filament
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pfus_cloud_unavailable_preserves_setting_id():
|
||||
"""Reporter scenario: PFUS cloud user preset, cloud lookup fails to
|
||||
return a filament_id. setting_id must survive so the slicer can
|
||||
still find the user's actual custom preset."""
|
||||
db = MagicMock()
|
||||
with patch(
|
||||
"backend.app.api.routes.cloud.build_authenticated_cloud",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
tray_info_idx, setting_id, sub_brand = await resolve_slicer_filament(
|
||||
db=db,
|
||||
current_user=None,
|
||||
slicer_filament="PFUS990b6e19965353",
|
||||
slicer_filament_name="Jayo PETG HF",
|
||||
material="PETG",
|
||||
)
|
||||
assert tray_info_idx == ""
|
||||
assert setting_id == "PFUS990b6e19965353"
|
||||
assert sub_brand is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pfcn_cloud_unavailable_preserves_setting_id():
|
||||
"""PFCN partner/shared cloud preset (e.g. Polymaker H2D variants,
|
||||
#1648) shares the same shape problem as PFUS."""
|
||||
db = MagicMock()
|
||||
with patch(
|
||||
"backend.app.api.routes.cloud.build_authenticated_cloud",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
tray_info_idx, setting_id, sub_brand = await resolve_slicer_filament(
|
||||
db=db,
|
||||
current_user=None,
|
||||
slicer_filament="PFCN1234567890",
|
||||
slicer_filament_name="Polymaker PolyTerra PLA",
|
||||
material="PLA",
|
||||
)
|
||||
assert tray_info_idx == ""
|
||||
assert setting_id == "PFCN1234567890"
|
||||
assert sub_brand is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pfus_cloud_resolves_filament_id_regression_guard():
|
||||
"""When cloud auth works and returns a filament_id, the resolver
|
||||
keeps its existing behaviour: tray_info_idx = real filament_id,
|
||||
setting_id = original PFUS reference."""
|
||||
db = MagicMock()
|
||||
cloud_mock = MagicMock()
|
||||
cloud_mock.is_authenticated = True
|
||||
cloud_mock.get_setting_detail = AsyncMock(return_value={"filament_id": "P285e239", "name": "Jayo PETG HF @P1S"})
|
||||
cloud_mock.close = AsyncMock()
|
||||
with patch(
|
||||
"backend.app.api.routes.cloud.build_authenticated_cloud",
|
||||
AsyncMock(return_value=cloud_mock),
|
||||
):
|
||||
tray_info_idx, setting_id, sub_brand = await resolve_slicer_filament(
|
||||
db=db,
|
||||
current_user=MagicMock(),
|
||||
slicer_filament="PFUS990b6e19965353",
|
||||
slicer_filament_name="Jayo PETG HF",
|
||||
material="PETG",
|
||||
)
|
||||
assert tray_info_idx == "P285e239"
|
||||
assert setting_id == "PFUS990b6e19965353"
|
||||
assert sub_brand == "Jayo PETG HF"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gfs_cloud_unavailable_resolves_via_normalize():
|
||||
"""GFS Bambu official preset + cloud unavailable: normalize strips
|
||||
the 'S' to give a real filament_id ('GFG02'), so tray_info_idx is
|
||||
valid and the defensive filter doesn't trigger. setting_id stays as
|
||||
the original GFS reference. Regression guard for the cloud-down
|
||||
Bambu-official path."""
|
||||
db = MagicMock()
|
||||
with patch(
|
||||
"backend.app.api.routes.cloud.build_authenticated_cloud",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
tray_info_idx, setting_id, sub_brand = await resolve_slicer_filament(
|
||||
db=db,
|
||||
current_user=None,
|
||||
slicer_filament="GFSG02",
|
||||
slicer_filament_name=None,
|
||||
material="PETG",
|
||||
)
|
||||
assert tray_info_idx == "GFG02"
|
||||
assert setting_id == "GFSG02"
|
||||
assert sub_brand is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_literal_material_name_clears_both():
|
||||
"""slicer_filament='PETG' (free-text material leak from legacy
|
||||
spools): both tray_info_idx and setting_id must be cleared so the
|
||||
caller's generic-material fallback rescues the slot. Regression
|
||||
guard that the PFUS preservation doesn't accidentally preserve
|
||||
literal material names."""
|
||||
db = MagicMock()
|
||||
with patch(
|
||||
"backend.app.api.routes.cloud.build_authenticated_cloud",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
tray_info_idx, setting_id, sub_brand = await resolve_slicer_filament(
|
||||
db=db,
|
||||
current_user=None,
|
||||
slicer_filament="PETG",
|
||||
slicer_filament_name=None,
|
||||
material="PETG",
|
||||
)
|
||||
assert tray_info_idx == ""
|
||||
assert setting_id == ""
|
||||
assert sub_brand is None
|
||||
@@ -47,7 +47,11 @@ def queue_factory(db_session, printer_factory):
|
||||
printer_holder["p"] = await printer_factory()
|
||||
return printer_holder["p"]
|
||||
|
||||
async def _add(status: str, error_message: str | None = None) -> PrintQueueItem:
|
||||
async def _add(
|
||||
status: str,
|
||||
error_message: str | None = None,
|
||||
gate_acknowledged: bool = False,
|
||||
) -> PrintQueueItem:
|
||||
printer = await _make_printer()
|
||||
counter["n"] += 1
|
||||
item = PrintQueueItem(
|
||||
@@ -56,6 +60,7 @@ def queue_factory(db_session, printer_factory):
|
||||
error_message=error_message,
|
||||
completed_at=base_time + timedelta(minutes=counter["n"]),
|
||||
require_previous_success=True,
|
||||
gate_acknowledged=gate_acknowledged,
|
||||
)
|
||||
db_session.add(item)
|
||||
await db_session.commit()
|
||||
@@ -170,3 +175,44 @@ async def test_completed_then_failed_blocks(scheduler, db_session, queue_factory
|
||||
await queue_factory["add"]("failed")
|
||||
pending = await queue_factory["add_pending"]()
|
||||
assert await scheduler._check_previous_success(db_session, pending) is False
|
||||
|
||||
|
||||
# ---- #1818: per-printer Resume-after-failure gate acknowledgement ----
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acknowledged_failure_is_excluded(scheduler, db_session, queue_factory):
|
||||
"""The reporter scenario: failure with gate_acknowledged=True must NOT
|
||||
block. Without the acknowledge filter, a single failure poisons every
|
||||
later require_previous_success item forever."""
|
||||
await queue_factory["add"]("failed", gate_acknowledged=True)
|
||||
pending = await queue_factory["add_pending"]()
|
||||
assert await scheduler._check_previous_success(db_session, pending) is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acknowledged_aborted_is_excluded(scheduler, db_session, queue_factory):
|
||||
await queue_factory["add"]("aborted", gate_acknowledged=True)
|
||||
pending = await queue_factory["add_pending"]()
|
||||
assert await scheduler._check_previous_success(db_session, pending) is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fresh_failure_after_ack_still_blocks(scheduler, db_session, queue_factory):
|
||||
"""Per-item acknowledgement is independent — a NEW failure after the
|
||||
user resumed the queue must re-gate downstream items so they don't
|
||||
silently steamroll past a real problem."""
|
||||
await queue_factory["add"]("failed", gate_acknowledged=True) # the old one
|
||||
await queue_factory["add"]("failed", gate_acknowledged=False) # fresh post-resume
|
||||
pending = await queue_factory["add_pending"]()
|
||||
assert await scheduler._check_previous_success(db_session, pending) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acknowledged_failure_walks_back_to_completed(scheduler, db_session, queue_factory):
|
||||
"""After acknowledging the failure, the next real predecessor (a
|
||||
completed print prior to the failure) governs the gate."""
|
||||
await queue_factory["add"]("completed")
|
||||
await queue_factory["add"]("failed", gate_acknowledged=True)
|
||||
pending = await queue_factory["add_pending"]()
|
||||
assert await scheduler._check_previous_success(db_session, pending) is True
|
||||
|
||||
@@ -4884,6 +4884,17 @@ export const api = {
|
||||
const qs = opts?.skipFilamentCheck ? '?skip_filament_check=true' : '';
|
||||
return request<PrintQueueItem>(`/queue/${id}/start${qs}`, { method: 'POST' });
|
||||
},
|
||||
/**
|
||||
* Clear the `require_previous_success` gate for a printer after the user
|
||||
* resolves the failure. Acknowledges any failed/aborted predecessors and
|
||||
* restores skipped items whose error_message matches the gate string
|
||||
* back to pending. Returns counts so the UI can render a precise toast.
|
||||
*/
|
||||
resumeQueueAfterFailure: (printerId: number) =>
|
||||
request<{ acknowledged: number; restored: number }>(
|
||||
`/queue/printer/${printerId}/resume`,
|
||||
{ method: 'POST' },
|
||||
),
|
||||
bulkUpdateQueue: (data: PrintQueueBulkUpdate) =>
|
||||
request<PrintQueueBulkUpdateResponse>('/queue/bulk', {
|
||||
method: 'PATCH',
|
||||
|
||||
@@ -1320,6 +1320,15 @@ export default {
|
||||
batchCreateFailed: 'Stapel konnte nicht erstellt werden',
|
||||
batchUngrouped: '{{count}} Eintrag/Einträge aus Stapel gelöst',
|
||||
batchUngroupFailed: 'Stapel konnte nicht aufgelöst werden',
|
||||
resumedAfterFailure: 'Warteschlange fortgesetzt — {{restored}} Auftrag/Aufträge wieder eingereiht',
|
||||
resumeAfterFailureFailed: 'Warteschlange konnte nicht fortgesetzt werden',
|
||||
},
|
||||
resumeAfterFailure: {
|
||||
banner: '{{printer}} ist durch einen vorherigen Druckfehler blockiert — {{count}} Auftrag/Aufträge übersprungen',
|
||||
bannerHint: 'Behebe das Druckerproblem und setze die Warteschlange dann fort, um die übersprungenen Aufträge wiederherzustellen und die Sperre aufzuheben.',
|
||||
button: 'Nach Fehler fortsetzen',
|
||||
confirmTitle: 'Warteschlange nach Fehler fortsetzen?',
|
||||
confirmMessage: 'Setze {{count}} übersprungenen Auftrag/Aufträge auf {{printer}} wieder auf „Ausstehend“ und hebe die Vorgängersperre auf. Stelle vorher sicher, dass der Drucker bereit ist.',
|
||||
},
|
||||
// Timeline view
|
||||
timeline: {
|
||||
|
||||
@@ -1330,6 +1330,15 @@ export default {
|
||||
batchCreateFailed: 'Failed to create batch',
|
||||
batchUngrouped: 'Ungrouped {{count}} item(s)',
|
||||
batchUngroupFailed: 'Failed to ungroup batch',
|
||||
resumedAfterFailure: 'Resumed queue — {{restored}} job(s) restored to pending',
|
||||
resumeAfterFailureFailed: 'Failed to resume queue',
|
||||
},
|
||||
resumeAfterFailure: {
|
||||
banner: '{{printer}} is blocked by a previous-print failure — {{count}} job(s) skipped',
|
||||
bannerHint: 'Fix the printer issue, then resume to restore the skipped jobs and clear the gate.',
|
||||
button: 'Resume after failure',
|
||||
confirmTitle: 'Resume queue after failure?',
|
||||
confirmMessage: 'Restore {{count}} skipped job(s) on {{printer}} to pending and clear the previous-print gate. Make sure the printer is ready before continuing.',
|
||||
},
|
||||
// Timeline view
|
||||
timeline: {
|
||||
|
||||
@@ -1229,6 +1229,15 @@ export default {
|
||||
batchCreateFailed: 'Error al crear el lote',
|
||||
batchUngrouped: '{{count}} elemento(s) desagrupado(s)',
|
||||
batchUngroupFailed: 'Error al desagrupar el lote',
|
||||
resumedAfterFailure: 'Cola reanudada — {{restored}} trabajo(s) restaurado(s) a pendientes',
|
||||
resumeAfterFailureFailed: 'Error al reanudar la cola',
|
||||
},
|
||||
resumeAfterFailure: {
|
||||
banner: '{{printer}} está bloqueado por un fallo de impresión previo — {{count}} trabajo(s) omitido(s)',
|
||||
bannerHint: 'Soluciona el problema de la impresora y reanuda para restaurar los trabajos omitidos y limpiar el bloqueo.',
|
||||
button: 'Reanudar tras fallo',
|
||||
confirmTitle: '¿Reanudar la cola tras el fallo?',
|
||||
confirmMessage: 'Restaurar {{count}} trabajo(s) omitido(s) en {{printer}} a pendientes y limpiar el bloqueo del trabajo anterior. Asegúrate de que la impresora esté lista antes de continuar.',
|
||||
},
|
||||
// Timeline view
|
||||
timeline: {
|
||||
|
||||
@@ -1318,6 +1318,15 @@ export default {
|
||||
batchCreateFailed: 'Échec de la création du lot',
|
||||
batchUngrouped: '{{count}} élément(s) dégroupé(s)',
|
||||
batchUngroupFailed: 'Échec du dégroupement du lot',
|
||||
resumedAfterFailure: 'File reprise — {{restored}} tâche(s) restaurée(s) en attente',
|
||||
resumeAfterFailureFailed: 'Échec de la reprise de la file',
|
||||
},
|
||||
resumeAfterFailure: {
|
||||
banner: '{{printer}} est bloquée par un échec d\'impression précédent — {{count}} tâche(s) ignorée(s)',
|
||||
bannerHint: 'Résolvez le problème de l\'imprimante, puis reprenez la file pour restaurer les tâches ignorées et lever le blocage.',
|
||||
button: 'Reprendre après échec',
|
||||
confirmTitle: 'Reprendre la file après l\'échec ?',
|
||||
confirmMessage: 'Restaurer {{count}} tâche(s) ignorée(s) sur {{printer}} en attente et lever le blocage de l\'impression précédente. Assurez-vous que l\'imprimante est prête avant de continuer.',
|
||||
},
|
||||
// Timeline view
|
||||
timeline: {
|
||||
|
||||
@@ -1318,6 +1318,15 @@ export default {
|
||||
batchCreateFailed: 'Creazione lotto non riuscita',
|
||||
batchUngrouped: '{{count}} elemento/i separato/i',
|
||||
batchUngroupFailed: 'Separazione del lotto non riuscita',
|
||||
resumedAfterFailure: 'Coda ripresa — {{restored}} processo/i ripristinato/i come in attesa',
|
||||
resumeAfterFailureFailed: 'Ripresa della coda non riuscita',
|
||||
},
|
||||
resumeAfterFailure: {
|
||||
banner: '{{printer}} è bloccata da un errore di stampa precedente — {{count}} processo/i saltato/i',
|
||||
bannerHint: 'Risolvi il problema della stampante, poi riprendi per ripristinare i processi saltati e rimuovere il blocco.',
|
||||
button: 'Riprendi dopo errore',
|
||||
confirmTitle: 'Riprendere la coda dopo l\'errore?',
|
||||
confirmMessage: 'Ripristinare {{count}} processo/i saltato/i su {{printer}} come in attesa e rimuovere il blocco della stampa precedente. Assicurati che la stampante sia pronta prima di continuare.',
|
||||
},
|
||||
// Timeline view
|
||||
timeline: {
|
||||
|
||||
@@ -1317,6 +1317,15 @@ export default {
|
||||
batchCreateFailed: 'バッチの作成に失敗しました',
|
||||
batchUngrouped: '{{count}}件のグループを解除しました',
|
||||
batchUngroupFailed: 'バッチのグループ解除に失敗しました',
|
||||
resumedAfterFailure: 'キューを再開しました — {{restored}}件のジョブを保留状態に戻しました',
|
||||
resumeAfterFailureFailed: 'キューの再開に失敗しました',
|
||||
},
|
||||
resumeAfterFailure: {
|
||||
banner: '{{printer}} は前回の印刷失敗によりブロックされています — {{count}}件のジョブをスキップしました',
|
||||
bannerHint: 'プリンターの問題を解決してから再開すると、スキップされたジョブを復元してブロックを解除できます。',
|
||||
button: '失敗後に再開',
|
||||
confirmTitle: '失敗後にキューを再開しますか?',
|
||||
confirmMessage: '{{printer}} のスキップされた {{count}} 件のジョブを保留状態に戻し、前回の印刷ブロックを解除します。続行する前にプリンターが準備完了していることを確認してください。',
|
||||
},
|
||||
// Timeline view
|
||||
timeline: {
|
||||
|
||||
@@ -1161,6 +1161,15 @@ export default {
|
||||
batchCreateFailed: '배치 만들기에 실패했습니다',
|
||||
batchUngrouped: '{{count}}개 항목의 그룹을 해제했습니다',
|
||||
batchUngroupFailed: '배치 그룹 해제에 실패했습니다',
|
||||
resumedAfterFailure: '큐를 재개했습니다 — {{restored}}개 작업을 대기 상태로 복원했습니다',
|
||||
resumeAfterFailureFailed: '큐 재개에 실패했습니다',
|
||||
},
|
||||
resumeAfterFailure: {
|
||||
banner: '{{printer}}이(가) 이전 인쇄 실패로 인해 차단되었습니다 — {{count}}개 작업이 건너뛰어졌습니다',
|
||||
bannerHint: '프린터 문제를 해결한 후 재개하여 건너뛴 작업을 복원하고 차단을 해제하세요.',
|
||||
button: '실패 후 재개',
|
||||
confirmTitle: '실패 후 큐를 재개하시겠습니까?',
|
||||
confirmMessage: '{{printer}}에서 건너뛴 {{count}}개 작업을 대기 상태로 복원하고 이전 인쇄 차단을 해제합니다. 계속하기 전에 프린터가 준비되었는지 확인하세요.',
|
||||
},
|
||||
timeline: {
|
||||
listView: '목록',
|
||||
|
||||
@@ -1318,6 +1318,15 @@ export default {
|
||||
batchCreateFailed: 'Falha ao criar o lote',
|
||||
batchUngrouped: '{{count}} item(ns) desagrupado(s)',
|
||||
batchUngroupFailed: 'Falha ao desagrupar o lote',
|
||||
resumedAfterFailure: 'Fila retomada — {{restored}} trabalho(s) restaurado(s) para pendente',
|
||||
resumeAfterFailureFailed: 'Falha ao retomar a fila',
|
||||
},
|
||||
resumeAfterFailure: {
|
||||
banner: '{{printer}} está bloqueada por uma falha de impressão anterior — {{count}} trabalho(s) ignorado(s)',
|
||||
bannerHint: 'Resolva o problema da impressora e retome para restaurar os trabalhos ignorados e remover o bloqueio.',
|
||||
button: 'Retomar após falha',
|
||||
confirmTitle: 'Retomar a fila após a falha?',
|
||||
confirmMessage: 'Restaurar {{count}} trabalho(s) ignorado(s) em {{printer}} para pendente e remover o bloqueio da impressão anterior. Verifique se a impressora está pronta antes de continuar.',
|
||||
},
|
||||
// Timeline view
|
||||
timeline: {
|
||||
|
||||
@@ -1229,6 +1229,15 @@ export default {
|
||||
batchCreateFailed: 'Yığın oluşturma başarısız',
|
||||
batchUngrouped: '{{count}} öğe gruptan çıkarıldı',
|
||||
batchUngroupFailed: 'Yığını gruptan çıkarma başarısız',
|
||||
resumedAfterFailure: 'Kuyruk devam ettirildi — {{restored}} iş bekleyene geri alındı',
|
||||
resumeAfterFailureFailed: 'Kuyruk devam ettirilemedi',
|
||||
},
|
||||
resumeAfterFailure: {
|
||||
banner: '{{printer}} önceki bir baskı hatası nedeniyle engellendi — {{count}} iş atlandı',
|
||||
bannerHint: 'Yazıcı sorununu çözün, ardından atlanan işleri geri yüklemek ve engellemeyi kaldırmak için devam ettirin.',
|
||||
button: 'Hatadan sonra devam et',
|
||||
confirmTitle: 'Hatadan sonra kuyruğa devam edilsin mi?',
|
||||
confirmMessage: '{{printer}} üzerindeki {{count}} atlanan işi bekleyene geri yükleyin ve önceki baskı engelini kaldırın. Devam etmeden önce yazıcının hazır olduğundan emin olun.',
|
||||
},
|
||||
// Zaman çizelgesi görünümü
|
||||
timeline: {
|
||||
|
||||
@@ -1320,6 +1320,15 @@ export default {
|
||||
batchCreateFailed: '创建批次失败',
|
||||
batchUngrouped: '已取消分组 {{count}} 项',
|
||||
batchUngroupFailed: '取消批次分组失败',
|
||||
resumedAfterFailure: '已恢复队列 — {{restored}} 个任务已恢复为待处理',
|
||||
resumeAfterFailureFailed: '恢复队列失败',
|
||||
},
|
||||
resumeAfterFailure: {
|
||||
banner: '{{printer}} 因之前的打印失败而被阻塞 — 已跳过 {{count}} 个任务',
|
||||
bannerHint: '解决打印机问题后恢复队列,以还原跳过的任务并清除阻塞。',
|
||||
button: '失败后恢复',
|
||||
confirmTitle: '失败后恢复队列?',
|
||||
confirmMessage: '将 {{printer}} 上的 {{count}} 个跳过的任务恢复为待处理,并清除上一个打印的阻塞。继续之前请确保打印机已就绪。',
|
||||
},
|
||||
// Timeline view
|
||||
timeline: {
|
||||
|
||||
@@ -1319,6 +1319,15 @@ export default {
|
||||
batchCreateFailed: '建立批次失敗',
|
||||
batchUngrouped: '已取消分組 {{count}} 項',
|
||||
batchUngroupFailed: '取消批次分組失敗',
|
||||
resumedAfterFailure: '已恢復佇列 — {{restored}} 個工作已恢復為待處理',
|
||||
resumeAfterFailureFailed: '恢復佇列失敗',
|
||||
},
|
||||
resumeAfterFailure: {
|
||||
banner: '{{printer}} 因先前的列印失敗而被阻擋 — 已跳過 {{count}} 個工作',
|
||||
bannerHint: '解決印表機問題後恢復佇列,以還原跳過的工作並清除阻擋。',
|
||||
button: '失敗後恢復',
|
||||
confirmTitle: '失敗後恢復佇列?',
|
||||
confirmMessage: '將 {{printer}} 上的 {{count}} 個跳過的工作恢復為待處理,並清除上一個列印的阻擋。繼續前請確認印表機已就緒。',
|
||||
},
|
||||
// Timeline view
|
||||
timeline: {
|
||||
|
||||
@@ -3268,7 +3268,7 @@ export function ArchivesPage() {
|
||||
<div className="text-xs text-amber-200/80 mt-1">
|
||||
{t('archives.no3mfBanner.body')}{' '}
|
||||
<a
|
||||
href="https://bambuddy.cool/wiki/getting-started/#step-4-enable-store-sent-files-on-external-storage"
|
||||
href="https://wiki.bambuddy.cool/getting-started/#step-4-enable-store-sent-files-on-external-storage"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline hover:text-amber-100 inline-flex items-center gap-1"
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
PackageOpen,
|
||||
Ungroup,
|
||||
Ban,
|
||||
PlayCircle,
|
||||
} from 'lucide-react';
|
||||
import { api, ApiError } from '../api/client';
|
||||
import { type TimeFormat, formatETA, formatDuration, formatRelativeTime, parseUTCDate } from '../utils/date';
|
||||
@@ -1208,6 +1209,13 @@ export function QueuePage() {
|
||||
} | null>(null);
|
||||
const [selectedItems, setSelectedItems] = useState<number[]>([]);
|
||||
const [showBulkEditModal, setShowBulkEditModal] = useState(false);
|
||||
// #1818: per-printer Resume-after-failure confirm modal. Tracks which
|
||||
// printer's gate the user is about to clear; null when no modal is open.
|
||||
const [resumeConfirm, setResumeConfirm] = useState<{
|
||||
printerId: number;
|
||||
printerName: string;
|
||||
skippedCount: number;
|
||||
} | null>(null);
|
||||
const [historySortBy, setHistorySortBy] = useState<'date' | 'name' | 'printer'>(() => {
|
||||
const saved = localStorage.getItem('queue.historySortBy');
|
||||
return (saved as 'date' | 'name' | 'printer') || 'date';
|
||||
@@ -1431,6 +1439,21 @@ export function QueuePage() {
|
||||
onError: () => showToast(t('queue.toast.bulkCancelFailed'), 'error'),
|
||||
});
|
||||
|
||||
const resumeAfterFailureMutation = useMutation({
|
||||
mutationFn: (printerId: number) => api.resumeQueueAfterFailure(printerId),
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['queue'] });
|
||||
setResumeConfirm(null);
|
||||
showToast(
|
||||
t('queue.toast.resumedAfterFailure', {
|
||||
restored: result.restored,
|
||||
acknowledged: result.acknowledged,
|
||||
}),
|
||||
);
|
||||
},
|
||||
onError: () => showToast(t('queue.toast.resumeAfterFailureFailed'), 'error'),
|
||||
});
|
||||
|
||||
const createBatchMutation = useMutation({
|
||||
mutationFn: (data: { name: string; item_ids: number[] }) => api.createBatch(data),
|
||||
onSuccess: (batch) => {
|
||||
@@ -1823,6 +1846,42 @@ export function QueuePage() {
|
||||
});
|
||||
}, [groupedRows, t]);
|
||||
|
||||
// #1818: printers whose queue is gated by a prior failure that's poisoning
|
||||
// downstream `require_previous_success` items. We surface a per-printer
|
||||
// Resume banner above the active queue so the user can clear the gate +
|
||||
// restore the skipped jobs in one click, without re-queuing each one.
|
||||
// Detection key: skipped + the scheduler's exact gate string. Other skip
|
||||
// reasons (filament deficit, etc.) get their own UX and stay untouched.
|
||||
const gateBlockedPrinters = useMemo<
|
||||
Array<{ printerId: number; printerName: string; skippedCount: number }>
|
||||
>(() => {
|
||||
const counts = new Map<number, { name: string; count: number }>();
|
||||
queue?.forEach((item) => {
|
||||
if (
|
||||
item.status === 'skipped' &&
|
||||
item.error_message === 'Previous print failed or was aborted' &&
|
||||
item.printer_id
|
||||
) {
|
||||
const existing = counts.get(item.printer_id);
|
||||
if (existing) {
|
||||
existing.count += 1;
|
||||
} else {
|
||||
counts.set(item.printer_id, {
|
||||
name: item.printer_name || `Printer #${item.printer_id}`,
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return Array.from(counts.entries())
|
||||
.map(([printerId, { name, count }]) => ({
|
||||
printerId,
|
||||
printerName: name,
|
||||
skippedCount: count,
|
||||
}))
|
||||
.sort((a, b) => a.printerName.localeCompare(b.printerName));
|
||||
}, [queue]);
|
||||
|
||||
const aggregateForRows = (rows: QueueRow[]) => {
|
||||
let count = 0;
|
||||
let time = 0;
|
||||
@@ -1891,6 +1950,43 @@ export function QueuePage() {
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{/* #1818: Resume-after-failure banner. One row per printer whose queue
|
||||
is gated by a prior failed/aborted print. Visible regardless of
|
||||
tab/layout so the user can clear the gate without hunting for
|
||||
skipped items. Hidden entirely when no gates are active. */}
|
||||
{activeTab === 'queue' && gateBlockedPrinters.length > 0 && hasPermission('queue:update_all' as Permission) && (
|
||||
<div className="mb-4 space-y-2">
|
||||
{gateBlockedPrinters.map(({ printerId, printerName, skippedCount }) => (
|
||||
<div
|
||||
key={printerId}
|
||||
className="flex items-center gap-3 px-4 py-3 bg-orange-500/10 border border-orange-500/30 rounded-lg"
|
||||
>
|
||||
<AlertCircle className="w-5 h-5 text-orange-400 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-orange-200">
|
||||
{t('queue.resumeAfterFailure.banner', {
|
||||
printer: printerName,
|
||||
count: skippedCount,
|
||||
})}
|
||||
</div>
|
||||
<div className="text-xs text-orange-200/70 mt-0.5">
|
||||
{t('queue.resumeAfterFailure.bannerHint')}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
setResumeConfirm({ printerId, printerName, skippedCount })
|
||||
}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-orange-500/20 hover:bg-orange-500/30 text-orange-100 text-sm rounded-md border border-orange-500/40 transition-colors flex-shrink-0"
|
||||
>
|
||||
<PlayCircle className="w-4 h-4" />
|
||||
{t('queue.resumeAfterFailure.button')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-4 mb-6">
|
||||
<select
|
||||
@@ -2370,6 +2466,21 @@ export function QueuePage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* #1818: Resume-after-failure confirm */}
|
||||
{resumeConfirm && (
|
||||
<ConfirmModal
|
||||
title={t('queue.resumeAfterFailure.confirmTitle')}
|
||||
message={t('queue.resumeAfterFailure.confirmMessage', {
|
||||
printer: resumeConfirm.printerName,
|
||||
count: resumeConfirm.skippedCount,
|
||||
})}
|
||||
confirmText={t('queue.resumeAfterFailure.button')}
|
||||
variant="warning"
|
||||
onConfirm={() => resumeAfterFailureMutation.mutate(resumeConfirm.printerId)}
|
||||
onCancel={() => setResumeConfirm(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Clear History Confirm Modal */}
|
||||
{showClearHistoryConfirm && (
|
||||
<ConfirmModal
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -26,8 +26,8 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-ClT8LTw_.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Dteqqsyc.css">
|
||||
<script type="module" crossorigin src="/assets/index-Nmah4kx-.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DNsDLric.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user