From d568307eac5d0397f00c518bcbf5b2174e3a60a6 Mon Sep 17 00:00:00 2001 From: maziggy Date: Fri, 3 Jul 2026 08:32:51 +0200 Subject: [PATCH] fix(smart-plug): don't cut power when a print restarts, honor per-plug cooldown setting (#1890) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The print-queue "auto off after this job" trigger used a second, inline auto-off implementation (main.py, print_scheduler.py, print_queue.py) that hardcoded wait_for_cooldown(50C, 600s) — ignoring each plug's configured off_delay_mode / off_delay_minutes / off_temp_threshold — and ignored the return value, powering off on the 600s timeout regardless of print state. A print that failed and was reprinted from the touchscreen got its power cut mid-print. The inline tasks were also uncancellable, so a reprint couldn't abort a pending off. Consolidate all three into SmartPlugManager.schedule_off_after_queue_job, which schedules via the plug's configured strategy (shared with on_print_complete through _schedule_off_per_mode) and is cancellable via _pending_off. Add printer_manager.is_print_active() and guard the actual power-off in _delayed_off and _temp_based_off so no path cuts power on a loaded print. Move the on_print_start cancellation ahead of the auto_on gate so a reprint always aborts a pending off. --- CHANGELOG.md | 1 + backend/app/api/routes/print_queue.py | 39 +-- backend/app/main.py | 43 +--- backend/app/services/print_scheduler.py | 34 +-- backend/app/services/printer_manager.py | 18 ++ backend/app/services/smart_plug_manager.py | 105 ++++++++- .../unit/services/test_printer_manager.py | 40 ++++ .../unit/services/test_smart_plug_manager.py | 222 +++++++++++++++++- 8 files changed, 414 insertions(+), 88 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96eb5400b..236f3dccc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file. ## [0.2.5b2] - Unreleased ### Fixed +- **Auto power-off cuts power mid-print when a failed print is restarted from the printer's touchscreen, and ignored the plug's configured cooldown setting (#1890, reporter @owlery7)** — With "auto power off after finish" enabled, a print that failed and was then reprinted from the printer's finish screen got its power cut ~10 minutes in. **Root cause — two defects.** (1) There were two parallel auto-off systems. The correct one (`SmartPlugManager.on_print_complete`) honours each plug's configured off strategy (`off_delay_mode`: a time delay of `off_delay_minutes`, or a temperature threshold of `off_temp_threshold`), registers a *cancellable* task, and only fires on success. But the print-queue "auto off after this job" trigger used a **second, inline implementation** in three places (`main.py`, `print_scheduler.py`, `print_queue.py`) that hardcoded `wait_for_cooldown(target_temp=50°C, timeout=600s)` — ignoring the plug's `off_delay_mode`/`off_delay_minutes`/`off_temp_threshold` entirely — and, critically, **ignored the return value**: `wait_for_cooldown` returns `False` on its 600s timeout, but the caller powered off anyway. In the reporter's timeline: 04:02:14 the print failed → inline auto-off scheduled a 600s cooldown wait; 04:03:35 the user reprinted from the touchscreen (nozzle reheated, so it never reached 50°C); 04:12:14 the wait hit its 600s timeout, returned `False`, and the code cut power into the running reprint. (2) These inline tasks were fire-and-forget (`spawn_background_task`) with no cancellation, so a new print couldn't abort a pending off; and even the correct system's `on_print_start` cancellation was gated behind `auto_on`, so a plug with auto-on disabled kept its pending off. **Fix.** All three queue/scheduler auto-off triggers now delegate to a single new `SmartPlugManager.schedule_off_after_queue_job`, which schedules via each plug's configured strategy (shared with `on_print_complete` through a new `_schedule_off_per_mode` helper) — so the per-plug time-delay / temperature-threshold setting is finally honoured instead of a hardcoded 50°C/600s. Because these route through `_pending_off`, a reprint cancels them for free. A new `printer_manager.is_print_active()` guard (state ∈ {RUNNING, PAUSE, PREPARE, SLICING}) is checked immediately before the actual power-off in every executor — `_delayed_off` (fires unconditionally after N minutes), `_temp_based_off` (can trip during a reprint's PREPARE/heat phase), and the startup `resume_pending_auto_offs` time-mode immediate-off (a restart while an off was pending would otherwise power off a print that started during the downtime) — so no path cuts power on a loaded print; a stale pending off is cleared instead. The `on_print_start` cancellation was moved ahead of the `auto_on` gate so a reprint aborts a pending off regardless of that setting. The queue override still fires on failure (preserving intent) but is now protected by the active-print guard + cancellation. **Verification.** Reproduced the reporter's timeline end-to-end: the off now schedules with the plug's 5-min delay (300s, not 600s) and the reprint survives with no power cut. **Tests.** New `TestActivePrintGuard` in `test_smart_plug_manager.py` (delayed-off skips while printing / offs when idle; temp-off defers while printing / offs when cool+idle; queue-off uses the plug's time/temp settings regardless of global `auto_off`; skips disabled + HA-script plugs; reprint cancels a pending off even with `auto_on` disabled), a resume-on-restart guard test (stale pending off skipped + cleared while printing), plus an `is_print_active` state matrix in `test_printer_manager.py`. **Scope.** Backend only; consolidated three hardcoded copies into one settings-aware path (net code reduction). `wait_for_cooldown` is retained (correct, still unit-tested) but no longer used by the auto-off paths. - **Deleting a print from the archive fails with HTTP 403 for any request authenticated with an API key (#1888, reporter @MartinNYHC)** — `DELETE /api/v1/archives/{id}` (and the archive edit routes) rejected every API key with `{"detail":"API keys cannot be used for administrative operations"}`, regardless of the print's owner or the key's scopes, so automations that prune old prints were dead on arrival. **Root cause.** The delete route gates on `require_ownership_permission(ARCHIVES_DELETE_ALL, ARCHIVES_DELETE_OWN)`. Both permissions were in `_APIKEY_DENIED_PERMISSIONS` and absent from the `_APIKEY_SCOPE_BY_PERMISSION` allowlist in `core/auth.py`, so for an API key `_check_apikey_permissions` resolved each to scope `None` and raised the generic administrative-operations 403 — the entire archive-management surface (create / update / delete) was unreachable for API keys. This is the same regression class as the #1832 library/maintenance carve-outs, where splitting a management surface's OWN/ALL permissions across allowlist and denylist made it unreachable via `require_ownership_permission` (which gates on the ALL permission, and API keys have no per-row ownership identity). **Fix.** New per-key scope `can_manage_archives` (column on `api_keys`, DEFAULT TRUE for keys created via the UI going forward; existing rows backfill to FALSE so the upgrade path never silently widens scope — these permissions were explicitly denied for every key before, so nothing relies on them). `ARCHIVES_CREATE` / `ARCHIVES_UPDATE_OWN` / `ARCHIVES_UPDATE_ALL` / `ARCHIVES_DELETE_OWN` / `ARCHIVES_DELETE_ALL` moved from the denylist to the allowlist under this scope; OWN and ALL both map to it (matching `can_manage_library` / `can_queue`). `ARCHIVES_PURGE` stays admin-only — it drops the print's contribution to Quick Stats, mirroring `LIBRARY_PURGE`. `ARCHIVES_REPRINT_*` is unchanged (stays under `can_queue`). Users opt a key in from Settings → API Keys ("Manage Archives" toggle, with an "Archives" badge on the key list). The bundled SpoolBuddy kiosk key (created via the CLI) is set to `can_manage_archives=False` to stay minimally scoped. Migration is dialect-agnostic (`BOOLEAN` is valid on both SQLite and Postgres); verified end-to-end on fresh SQLite and Postgres 17 that the column adds, legacy rows backfill to FALSE, and the `column_existed` guard preserves a user's opt-in on subsequent restarts. **Tests.** `test_auth_apikey_rbac.py` extended: the `_check_apikey_permissions` scope matrix now covers all five archive management permissions (true→allow, false→403, no cross-scope leakage), `ARCHIVES_PURGE` added to the admin-denied matrix, `ARCHIVES_DELETE_ALL` / `ARCHIVES_UPDATE_ALL` added to the operational-allowed drift guard, and `can_manage_archives` threaded through the structural allowlist/flag-parity checks — 59 cases green. **Scope.** Backend (model + migration + allowlist + schema + route + CLI) plus the Settings API-key UI (toggle + badge + type) and 11-locale i18n for the new label/description/badge. No change to the archive routes themselves — they already gated on the right ownership permissions; only the API-key classification of those permissions was wrong. - **Server-side slice of a PLA-model / PVA-support 3MF silently loses the PVA — supports print in PLA, archive card hides the PVA tag (#1881, reporter @JonasFovea)** — Reporter uploaded a multi-material H2D Pro 3MF configured with PLA in slot 1 and PVA as support material in slot 2, hit Slice, picked a profile for both slots in the SliceModal. Result: the resulting `.gcode.3mf` carried a single filament (PLA) with no visible supports in the 3D preview, and the archive card never displayed the PVA badge for the source 3MF either. Local BambuStudio slice of the identical file with default settings worked. **Three distinct bugs on the same happy path**, discovered in sequence as each fix uncovered the next. **Bug A — `substitute_unused_plate_filaments` overwrites support-material slots.** The frontend does receive both filaments (log line `get_filament_info called with 4 IDs: ['GFA01', 'GFA00', 'GFG02', 'GFU00']`; `GFU` is Bambu's PVA prefix), the SliceModal renders two dropdowns via `extract_project_filaments_from_3mf`, the user picks a PVA profile for slot 2 and the payload arrives at `_run_slicer_with_fallback` intact. Before the sidecar call, `_run_slicer_with_fallback` (`backend/app/api/routes/library.py:3521`) invokes `substitute_unused_plate_filaments` — the helper that replaces "not used by this plate" slot entries with slot 1's profile so BambuStudio's loaded-filament temperature-spread validator doesn't reject the job (`temperature difference of the filaments used is too large`, exit 194). That helper delegates to `extract_plate_extruder_set_from_3mf` (`backend/app/utils/threemf_tools.py:878`) to enumerate which slots the plate actually references. The extractor walks three sources — `` top-level `extruder` metadata, per-`` overrides, and `paint_color` triangle quadtree leaves — all **object-geometry-derived**. **Support material is a process setting**, not object geometry: BambuStudio writes `support_filament` / `support_interface_filament` / `enable_support` into `Metadata/project_settings.config` and the slicer's process pass generates the support paths at slice time. The three geometry sources return `{1}`, `substitute_unused_plate_filaments` sees slot 2 as "unused", overwrites the user's PVA profile with slot 1's PLA profile, and the sidecar gets `[PLA_json, PLA_json]`. Silent, no error, no warning, no log line. **Bug B — `_extract_filament_info` strips support filaments from the tag list.** Independent from A: `backend/app/services/archive.py:377` was reading `filament_is_support` and excluding any slot where the flag was `"1"` from `filament_type` / `filament_color`. That's what drives the ArchiveCard's material badges. Result: even a correctly-sliced PLA+PVA `.gcode.3mf` would show only "PLA Basic" on the card. **Bug C — process preset overrides source's `enable_support`, `support_filament`, `support_interface_filament`, `support_type`.** Uncovered on the first test slice after A + B shipped: the sliced archive still had only PLA. Comparing `project_settings.config` in the source vs the sliced output: source has `enable_support: '1'` + `support_interface_filament: '2'`, sliced output has `enable_support: 0` + `support_interface_filament: 0`. Bambuddy passes the picked process preset via BambuStudio CLI's `--load-settings`, which is authoritative — every field in the loaded JSON overrides the source 3MF's embedded `project_settings.config`. Bambu's shipped process presets ("0.20mm Standard @BBL H2D" etc.) ship `enable_support: 0` because supports are a per-print decision, not a per-quality one. So even with the substitute-fix (A) sending both filaments to the CLI, `slice_info.config` in the output shows `support_used="false"` and only one `` entry consumed — PLA. The PVA slot loads but never gets referenced. **This inverts BambuStudio GUI's semantics** where the loaded project's settings are authoritative and the process preset is the inheritance backbone — but Bambuddy's `--load-settings` flow has preset winning over project, so any per-project setting the user configured before exporting the 3MF (supports on, PVA-for-interface, etc.) gets discarded on re-slice. **Fix A.** New helper `extract_support_filament_slots_from_3mf(zf)` in `threemf_tools.py` reads `enable_support` / `support_filament` / `support_interface_filament` from `project_settings.config` and returns the set of slots that must stay "used" for a plate print. Gated on `enable_support` (accepts BambuStudio's stringly-typed `"1"`/`"0"`, real booleans, and empty falsy variants); slot value `0` means "same as model" and is ignored; slot value `> 0` is added to the set. `substitute_unused_plate_filaments` unions this into the geometry-derived set — so a support-only slot is now correctly seen as "used" and the user's PVA profile survives to the slicer. When supports are DISABLED (or `support_filament == 0`), the substitution still runs and homogenises the loaded-filament array, preserving the pre-existing #1493 fix for the temperature-spread validator. **Fix B.** Removed the `filament_is_support` filter from `_extract_filament_info`. All configured filament types (PLA, PVA, etc.) now land on `filament_type` / `filament_color` in slot order with dedup on repeats. Sliced `.gcode.3mf` files are unaffected because they promote `_slice_filament_type` (parsed from `slice_info.config` which lists what the print actually consumed) over the project-settings fallback at `archive.py:150-155`; the filter change only affects unsliced source 3MFs that a user uploaded to the Archives page. **Fix C.** New helper `_patch_process_support_settings(process_json, source_3mf_bytes)` reads the source's `project_settings.config` and overlays four support-related fields onto the picked process preset JSON before `--load-settings` sees it: `enable_support`, `support_filament`, `support_interface_filament`, `support_type`. Deliberately targeted — the scope is what fixes #1881 without opening the semantic can of "should every project setting override every preset field" (which would need to reconcile #1201's sentinel handling, all the bed-type / prime-tower / brim / raft edge cases, and users who deliberately pick a preset to escape a broken 3MF's settings). Widening to more fields as follow-up if more "preserve X" reports come in. Silently no-ops on STL / STEP (no `project_settings.config`), on malformed sources, and on malformed presets — the slice then runs with the preset's own defaults, matching pre-fix behaviour on those paths. **Verification.** Ran the patch against the reporter's actual 3MF from Bambuddy's live DB (archive #262): source has `enable_support: '1'` + `support_interface_filament: '2'` + `support_type: 'normal(manual)'`, patched preset gets those exact values while its `layer_height: '0.20'` stays untouched. **Tests.** Nine new cases in `test_threemf_tools.py::TestExtractSupportFilamentSlotsFrom3mf` pin the helper (headline PLA+PVA scenario, distinct body/interface slots, `enable_support` off, slot-0 == same-as-model, JSON bool `enable_support`, integer vs string slot values, missing project settings, malformed JSON, non-numeric slot). Two new cases in `TestSubstituteUnusedPlateFilaments` end-to-end the substitution — `test_support_material_slot_preserved` reproduces the reporter's exact model_settings+project_settings shape and asserts slot 2's `pva_support.json` is NOT overwritten; `test_support_disabled_still_substitutes_unused` is the regression guard that turning supports off preserves the pre-existing homogenisation behaviour. Three new cases in `test_archive_service.py::TestThreeMFParserSupportMaterial` cover Bug B — reporter's PLA+PVA source ships both types + both colours to `filament_type`/`filament_color`, single-support-only degenerate case, duplicate types deduped while duplicate colours kept for the multi-colour path. New `test_slice_process_support_patch.py::TestPatchProcessSupportSettings` (9 cases) pins Fix C: reporter's exact scenario (source wins, layer_height preserved), source-off beats preset-on (symmetric direction), partial-source only patches keys it defines, no-project-settings passthrough, malformed source passthrough, malformed project-JSON passthrough, non-dict project settings passthrough, malformed preset passthrough, non-dict preset passthrough. 337/337 across all affected test files green, ruff clean. **Scope.** Backend-only. One new helper in `threemf_tools.py` (~40 LOC), one 3-line union in `slicer_3mf_convert.py`, one function body simplification in `archive.py`, one new helper in `library.py` (~35 LOC), one call-site in `_run_slicer_with_fallback`. No DB migration, no new permission, no i18n key, no frontend change. Users on 0.2.5b1 and earlier who tried server-side slicing a multi-material PVA-support 3MF: the slice will now actually include PVA supports on the next attempt after upgrade. Users who uploaded source 3MFs with PVA-for-support: the archive card will show both materials after a Bambuddy restart (badges are derived at parse time, so re-uploading refreshes the display; existing rows keep whatever they parsed with). diff --git a/backend/app/api/routes/print_queue.py b/backend/app/api/routes/print_queue.py index 43820cd8f..1a7711f84 100644 --- a/backend/app/api/routes/print_queue.py +++ b/backend/app/api/routes/print_queue.py @@ -16,7 +16,6 @@ from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_owners from backend.app.core.config import settings from backend.app.core.database import get_db from backend.app.core.permissions import Permission -from backend.app.core.tasks import spawn_background_task from backend.app.models.archive import PrintArchive from backend.app.models.library import LibraryFile from backend.app.models.print_batch import PrintBatch @@ -1272,9 +1271,7 @@ async def stop_queue_item( holding only _OWN saw the Stop button in the queue UI but got 403 on click. """ - from backend.app.models.smart_plug import SmartPlug from backend.app.services.printer_manager import printer_manager - from backend.app.services.tasmota import tasmota_service user, can_modify_all = auth_result @@ -1323,33 +1320,21 @@ async def stop_queue_item( item.error_message = "Stopped by user" if stop_sent else "Stopped by user (printer was offline)" await db.commit() - # Get smart plug info if auto-off is enabled - plug_ip = None - if auto_off_after: - result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id)) - plug = result.scalar_one_or_none() - if plug and plug.enabled: - plug_ip = plug.ip_address - logger.info("Stopped printing queue item %s (stop command sent: %s)", item_id, stop_sent) - # Schedule background task for cooldown + power off - if plug_ip: + # Schedule power-off if the queue item opted in. Delegates to the smart-plug + # manager so the off honours each plug's configured strategy (time delay or + # temperature threshold), is cancelled if the printer starts printing again, + # and never cuts power on a loaded print (#1890). Previously an inline block + # hardcoded a 50°C / 600s cooldown wait and powered off on the timeout + # regardless of print state. + if auto_off_after: + from backend.app.services.smart_plug_manager import smart_plug_manager - async def cooldown_and_poweroff(): - logger.info("Auto-off: Waiting for printer %s to cool down before power off...", printer_id) - await printer_manager.wait_for_cooldown(printer_id, target_temp=50.0, timeout=600) - # Re-fetch plug since we're in a new async context - from backend.app.core.database import async_session - - async with async_session() as new_db: - result = await new_db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id)) - plug = result.scalar_one_or_none() - if plug and plug.enabled: - logger.info("Auto-off: Powering off printer %s", printer_id) - await tasmota_service.turn_off(plug) - - spawn_background_task(cooldown_and_poweroff(), name=f"queue-cooldown-poweroff-{printer_id}") + try: + await smart_plug_manager.schedule_off_after_queue_job(printer_id, db) + except Exception as e: + logger.warning("Auto-off: Failed to schedule power-off for printer %s: %s", printer_id, e) return {"message": "Print stopped" if stop_sent else "Queue item cancelled (printer was offline)"} diff --git a/backend/app/main.py b/backend/app/main.py index 0f5b760c5..1f0cde424 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4333,38 +4333,19 @@ async def on_print_complete(printer_id: int, data: dict): except Exception: pass # Don't fail if notification fails - # Handle auto_off_after - power off printer if requested (after cooldown) + # Handle auto_off_after - power off printer if the queue item opted + # in. Delegates to the smart-plug manager so the off honours each + # plug's configured strategy (time delay or temperature threshold), + # is cancelled if the printer starts printing again, and never cuts + # power on a loaded print (#1890). Previously an inline block here + # hardcoded a 50°C / 600s cooldown wait and powered off on the + # timeout regardless of print state — cutting a touchscreen reprint. if queue_auto_off: - async with async_session() as db: - result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id)) - plugs = list(result.scalars().all()) - enabled_plugs = [p for p in plugs if p.enabled] - if enabled_plugs: - logger.info("Auto-off requested for printer %s, waiting for cooldown...", printer_id) - - async def cooldown_and_poweroff(pid: int, plug_ids: list[int]): - # Wait for nozzle to cool down - await printer_manager.wait_for_cooldown(pid, target_temp=50.0, timeout=600) - # Re-fetch plugs in new session and turn off each one - async with async_session() as new_db: - for plug_id in plug_ids: - try: - result = await new_db.execute(select(SmartPlug).where(SmartPlug.id == plug_id)) - p = result.scalar_one_or_none() - if p and p.enabled: - service = await smart_plug_manager.get_service_for_plug(p, new_db) - success = await service.turn_off(p) - if success: - logger.info("Powered off printer %s via smart plug '%s'", pid, p.name) - else: - logger.warning("Failed to power off plug '%s' for printer %s", p.name, pid) - except Exception as e: - logger.warning("Failed to power off plug %s for printer %s: %s", plug_id, pid, e) - - spawn_background_task( - cooldown_and_poweroff(printer_id, [p.id for p in enabled_plugs]), - name=f"cooldown-poweroff-{printer_id}", - ) + try: + async with async_session() as db: + await smart_plug_manager.schedule_off_after_queue_job(printer_id, db) + except Exception as e: + logger.warning("Failed to schedule queue auto-off for printer %s: %s", printer_id, e) except Exception as e: logging.getLogger(__name__).warning(f"Queue item update failed: {e}") diff --git a/backend/app/services/print_scheduler.py b/backend/app/services/print_scheduler.py index fee1dc245..3968ac64b 100644 --- a/backend/app/services/print_scheduler.py +++ b/backend/app/services/print_scheduler.py @@ -2329,30 +2329,20 @@ class PrintScheduler: return prev_item.status in ("completed", "cancelled") async def _power_off_if_needed(self, db: AsyncSession, item: PrintQueueItem): - """Power off printer if auto_off_after is enabled (waits for cooldown).""" + """Schedule power-off if the queue item enabled auto_off_after. + + Delegates to the smart-plug manager so the off honours each plug's + configured strategy (time delay or temperature threshold), is cancelled + if the printer starts printing again, and never cuts power on a loaded + print (#1890). Previously this hardcoded a 50°C / 600s cooldown wait and + powered off on the timeout regardless of print state. + """ if not item.auto_off_after: return - - plugs = await self._get_smart_plugs(db, item.printer_id) - plug_ids = [p.id for p in plugs if p.enabled] - if plug_ids: - logger.info("Auto-off: Waiting for printer %s to cool down before power off...", item.printer_id) - # Wait for cooldown (up to 10 minutes) - await printer_manager.wait_for_cooldown(item.printer_id, target_temp=50.0, timeout=600) - # Re-fetch plugs in a fresh session after the long cooldown wait - async with async_session() as new_db: - for plug_id in plug_ids: - try: - result = await new_db.execute(select(SmartPlug).where(SmartPlug.id == plug_id)) - plug = result.scalar_one_or_none() - if plug and plug.enabled: - logger.info("Auto-off: Powering off plug '%s' for printer %s", plug.name, item.printer_id) - service = await smart_plug_manager.get_service_for_plug(plug, new_db) - await service.turn_off(plug) - except Exception as e: - logger.warning( - "Auto-off: Failed to power off plug %s for printer %s: %s", plug_id, item.printer_id, e - ) + try: + await smart_plug_manager.schedule_off_after_queue_job(item.printer_id, db) + except Exception as e: + logger.warning("Auto-off: Failed to schedule power-off for printer %s: %s", item.printer_id, e) async def _get_job_name(self, db: AsyncSession, item: PrintQueueItem) -> str: """Get a human-readable name for a queue item.""" diff --git a/backend/app/services/printer_manager.py b/backend/app/services/printer_manager.py index 15b4d5ef0..5494f9a2e 100644 --- a/backend/app/services/printer_manager.py +++ b/backend/app/services/printer_manager.py @@ -602,6 +602,24 @@ class PrinterManager: return client.state return None + # Gcode states in which a job is loaded / in progress and cutting power + # would ruin the print. PAUSE is included on purpose — a paused print is + # still loaded on the bed. Used by the smart-plug auto-off guard (#1890) so + # a re-print started from the touchscreen isn't killed mid-print. + ACTIVE_PRINT_STATES = ("RUNNING", "PAUSE", "PREPARE", "SLICING") + + def is_print_active(self, printer_id: int) -> bool: + """True when the printer currently has a print loaded / in progress. + + Returns False when disconnected or in any idle/terminal state + (IDLE / FINISH / FAILED / unknown), so callers fail *open* only for + the safe "nothing is printing" case. #1890. + """ + state = self.get_status(printer_id) + if not state or not state.connected: + return False + return state.state in self.ACTIVE_PRINT_STATES + def get_model(self, printer_id: int) -> str | None: """Get the cached model for a printer.""" return self._models.get(printer_id) diff --git a/backend/app/services/smart_plug_manager.py b/backend/app/services/smart_plug_manager.py index ad7312496..d3b716780 100644 --- a/backend/app/services/smart_plug_manager.py +++ b/backend/app/services/smart_plug_manager.py @@ -227,13 +227,16 @@ class SmartPlugManager: logger.debug("Smart plug '%s' is disabled, skipping auto-on", plug.name) continue + # Cancel any pending off task FIRST — a re-print must abort a + # scheduled auto-off regardless of the plug's auto_on setting + # (#1890). Previously this lived behind the auto_on gate, so a plug + # with auto_on disabled kept its pending off and cut power mid-print. + self._cancel_pending_off(plug.id) + if not plug.auto_on: logger.debug("Smart plug '%s' auto_on is disabled", plug.name) continue - # Cancel any pending off task - self._cancel_pending_off(plug.id) - # Turn on the plug logger.info("Print started on printer %s, turning on plug '%s'", printer_id, plug.name) try: @@ -289,10 +292,51 @@ class SmartPlugManager: plug.name, ) - if plug.off_delay_mode == "time": - self._schedule_delayed_off(plug, printer_id, plug.off_delay_minutes * 60) - elif plug.off_delay_mode == "temperature": - self._schedule_temp_based_off(plug, printer_id, plug.off_temp_threshold) + self._schedule_off_per_mode(plug, printer_id) + + def _schedule_off_per_mode(self, plug: "SmartPlug", printer_id: int): + """Schedule an auto-off using the plug's configured off strategy. + + Honours the per-plug ``off_delay_mode`` — ``time`` waits + ``off_delay_minutes``; ``temperature`` waits until the nozzle drops + below ``off_temp_threshold`` (#1890 — the queue/scheduler auto-off + paths used to hardcode 50°C / 600s and ignore these settings). Both + branches register a cancellable task in ``_pending_off``, so a re-print + cancels the pending off via :meth:`on_print_start`. + """ + if plug.off_delay_mode == "temperature": + self._schedule_temp_based_off(plug, printer_id, plug.off_temp_threshold) + else: + # Default / "time": also the safe fallback for any unexpected value. + self._schedule_delayed_off(plug, printer_id, plug.off_delay_minutes * 60) + + async def schedule_off_after_queue_job(self, printer_id: int, db: AsyncSession): + """Schedule auto-off for a printer after a queue job that opted in. + + The print-queue "auto off after this job" toggle (`auto_off_after`) is + a per-job override, independent of the plug's global ``auto_off`` flag — + so unlike :meth:`on_print_complete` this does NOT gate on ``plug.auto_off``. + It still honours ``enabled`` and skips HA-script entities (which can only + be triggered, not turned off), and uses each plug's configured off + strategy via :meth:`_schedule_off_per_mode`. Replaces the three inline + ``wait_for_cooldown(50°C, 600s)`` blocks that ignored plug settings, + fired on the cooldown *timeout* regardless of print state, and could not + be cancelled by a re-print (#1890). + """ + plugs = await self._get_plugs_for_printer(printer_id, db) + for plug in plugs: + if not plug.enabled: + logger.debug("Smart plug '%s' is disabled, skipping queue auto-off", plug.name) + continue + if plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script."): + logger.debug("Smart plug '%s' is a HA script entity, skipping queue auto-off", plug.name) + continue + logger.info( + "Queue job finished on printer %s, scheduling turn-off for plug '%s'", + printer_id, + plug.name, + ) + self._schedule_off_per_mode(plug, printer_id) async def on_drying_complete(self, printer_id: int, db: AsyncSession): """Schedule turn-off for plugs flagged ``auto_off_after_drying`` when @@ -381,6 +425,21 @@ class SmartPlugManager: try: await asyncio.sleep(delay_seconds) + # #1890: never cut power while a print is loaded / running. The + # delay fires unconditionally after N minutes, so if the user + # re-started (or reprinted) in the meantime, the printer is active + # again — skip the off and clear the pending flag rather than + # killing the print mid-way. + if printer_manager.is_print_active(printer_id): + logger.info( + "Skipping auto-off for plug %s: printer %s is printing again (state=%s)", + plug_id, + printer_id, + getattr(printer_manager.get_status(printer_id), "state", "unknown"), + ) + await self._mark_auto_off_pending(plug_id, False) + return + # Create a minimal plug-like object for the service class PlugInfo: def __init__(self): @@ -489,6 +548,22 @@ class SmartPlugManager: ) if max_nozzle_temp < temp_threshold: + # #1890: the nozzle can dip below the threshold between + # a finished print and a fresh one starting (e.g. a + # touchscreen reprint during the PREPARE/heating phase). + # Guard the turn-off so we never cut power on a loaded + # print; keep polling until it's genuinely idle again. + if printer_manager.is_print_active(printer_id): + logger.info( + "Deferring temp-based auto-off for plug %s: printer %s is printing again (state=%s)", + plug_id, + printer_id, + getattr(printer_manager.get_status(printer_id), "state", "unknown"), + ) + await asyncio.sleep(check_interval) + elapsed += check_interval + continue + # All nozzles are below threshold, turn off class PlugInfo: def __init__(self): @@ -626,6 +701,22 @@ class SmartPlugManager: logger.info("Resuming pending auto-off for plug '%s' (printer %s)", plug.name, plug.printer_id) + # #1890: never resume a power-off onto a live print. If the + # printer started a new print during the downtime, the stale + # pending off must be dropped, not executed — same guard the + # live off-executors use. + if printer_manager.is_print_active(plug.printer_id): + logger.info( + "Not resuming auto-off for plug '%s': printer %s is printing (state=%s); clearing pending", + plug.name, + plug.printer_id, + getattr(printer_manager.get_status(plug.printer_id), "state", "unknown"), + ) + plug.auto_off_pending = False + plug.auto_off_pending_since = None + await db.commit() + continue + # Resume the appropriate off mode if plug.off_delay_mode == "temperature": self._schedule_temp_based_off(plug, plug.printer_id, plug.off_temp_threshold) diff --git a/backend/tests/unit/services/test_printer_manager.py b/backend/tests/unit/services/test_printer_manager.py index 327385747..0daccf548 100644 --- a/backend/tests/unit/services/test_printer_manager.py +++ b/backend/tests/unit/services/test_printer_manager.py @@ -476,6 +476,46 @@ class TestPrinterManager: assert result is True + # ======================================================================== + # Tests for is_print_active (#1890) + # ======================================================================== + + @pytest.mark.parametrize( + "state,expected", + [ + ("RUNNING", True), + ("PAUSE", True), + ("PREPARE", True), + ("SLICING", True), + ("FINISH", False), + ("IDLE", False), + ("FAILED", False), + ("unknown", False), + ], + ) + def test_is_print_active_state_matrix(self, manager, mock_client, state, expected): + """A job-loaded state is 'active'; idle/terminal states are not.""" + mock_client.state.connected = True + mock_client.state.state = state + mock_client.check_staleness.return_value = True + manager._clients[1] = mock_client + + assert manager.is_print_active(1) is expected + + def test_is_print_active_false_when_disconnected(self, manager, mock_client): + """Even in RUNNING, a disconnected printer is not treated as active — + we fail safe (no active print) only for the 'nothing printing' cases.""" + mock_client.state.connected = False + mock_client.state.state = "RUNNING" + mock_client.check_staleness.return_value = False + manager._clients[1] = mock_client + + assert manager.is_print_active(1) is False + + def test_is_print_active_false_for_unknown_printer(self, manager): + """Unknown printer id → not active (no client).""" + assert manager.is_print_active(999) is False + # ======================================================================== # Tests for logging methods # ======================================================================== diff --git a/backend/tests/unit/services/test_smart_plug_manager.py b/backend/tests/unit/services/test_smart_plug_manager.py index 2abbc0f90..aad665246 100644 --- a/backend/tests/unit/services/test_smart_plug_manager.py +++ b/backend/tests/unit/services/test_smart_plug_manager.py @@ -4,6 +4,7 @@ These tests specifically target the auto-off behavior and toggle functionality that were identified as common regression points. """ +import asyncio from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock, patch @@ -815,7 +816,7 @@ class TestPendingAutoOffPersistence: patch("backend.app.core.database.async_session") as mock_session_ctx, patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota, patch.object(manager, "_mark_auto_off_executed", new_callable=AsyncMock) as mock_mark, - patch("backend.app.services.smart_plug_manager.printer_manager"), + patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm, ): mock_db = AsyncMock() mock_result = MagicMock() @@ -826,8 +827,227 @@ class TestPendingAutoOffPersistence: mock_session_ctx.return_value.__aexit__ = AsyncMock() mock_tasmota.turn_off = AsyncMock(return_value=True) + mock_pm.is_print_active.return_value = False # printer idle on restart await manager.resume_pending_auto_offs() mock_tasmota.turn_off.assert_called_once() mock_mark.assert_called_once_with(1) + + @pytest.mark.asyncio + async def test_resume_pending_auto_off_skipped_when_printing(self, manager): + """#1890: on restart, a stale pending off must NOT power off a live print; + the pending flag is cleared instead.""" + mock_plug = MagicMock() + mock_plug.id = 1 + mock_plug.name = "Test Plug" + mock_plug.printer_id = 1 + mock_plug.auto_off_pending = True + mock_plug.auto_off_pending_since = datetime.now(timezone.utc) + mock_plug.off_delay_mode = "time" + + with ( + patch("backend.app.core.database.async_session") as mock_session_ctx, + patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota, + patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm, + patch.object(manager, "_schedule_temp_based_off") as mock_temp, + ): + mock_db = AsyncMock() + mock_result = MagicMock() + mock_result.scalars.return_value.all.return_value = [mock_plug] + mock_db.execute = AsyncMock(return_value=mock_result) + mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db) + mock_session_ctx.return_value.__aexit__ = AsyncMock() + + mock_tasmota.turn_off = AsyncMock(return_value=True) + mock_pm.is_print_active.return_value = True # printer printing again on restart + mock_pm.get_status.return_value = MagicMock(state="RUNNING") + + await manager.resume_pending_auto_offs() + + mock_tasmota.turn_off.assert_not_called() # never cut power on the live print + mock_temp.assert_not_called() + assert mock_plug.auto_off_pending is False # stale pending cleared + + +class TestActivePrintGuard: + """#1890 — auto-off must never cut power while a print is loaded/running. + + Covers the two off-executors (`_delayed_off`, `_temp_based_off`), the new + queue-override scheduler that honours per-plug settings, and the + on_print_start cancellation gap. + """ + + @pytest.fixture + def manager(self): + return SmartPlugManager() + + @pytest.fixture + def mock_plug(self): + plug = MagicMock() + plug.id = 1 + plug.name = "Test Plug" + plug.ip_address = "192.168.1.100" + plug.username = None + plug.password = None + plug.enabled = True + plug.auto_on = True + plug.auto_off = True + plug.off_delay_mode = "time" + plug.off_delay_minutes = 5 + plug.off_temp_threshold = 70 + plug.printer_id = 1 + plug.plug_type = "tasmota" + plug.ha_entity_id = None + return plug + + # ---- _delayed_off (time mode) ---------------------------------------- + + @pytest.mark.asyncio + async def test_delayed_off_skips_when_printer_printing_again(self, manager): + """Time-delay fires after N min; if a reprint is running, skip the off.""" + with ( + patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm, + patch.object(manager, "get_service_for_plug", new_callable=AsyncMock) as mock_get_svc, + patch.object(manager, "_mark_auto_off_pending", new_callable=AsyncMock) as mock_mark_pending, + patch.object(manager, "_mark_auto_off_executed", new_callable=AsyncMock) as mock_mark_exec, + ): + mock_pm.is_print_active.return_value = True + mock_pm.get_status.return_value = MagicMock(state="RUNNING") + + await manager._delayed_off(1, "tasmota", "1.2.3.4", None, None, None, printer_id=1, delay_seconds=0) + + mock_get_svc.assert_not_called() # never even resolved a service to turn off + mock_mark_exec.assert_not_called() + mock_mark_pending.assert_awaited_with(1, False) # pending flag cleared + + @pytest.mark.asyncio + async def test_delayed_off_powers_off_when_idle(self, manager): + """When the printer is genuinely idle, the delayed off still fires.""" + mock_service = AsyncMock() + mock_service.turn_off = AsyncMock(return_value=True) + with ( + patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm, + patch.object(manager, "get_service_for_plug", new_callable=AsyncMock, return_value=mock_service), + patch.object(manager, "_mark_auto_off_executed", new_callable=AsyncMock), + ): + mock_pm.is_print_active.return_value = False + + await manager._delayed_off(1, "tasmota", "1.2.3.4", None, None, None, printer_id=1, delay_seconds=0) + + mock_service.turn_off.assert_awaited_once() + mock_pm.mark_printer_offline.assert_called_once_with(1) + + # ---- _temp_based_off (temperature mode) ------------------------------ + + @pytest.mark.asyncio + async def test_temp_based_off_defers_while_printing_even_if_cool(self, manager): + """Nozzle can dip below threshold during a reprint's PREPARE/heat phase; + the guard must defer rather than cut power on the loaded print.""" + with ( + patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm, + patch("backend.app.services.smart_plug_manager.asyncio.sleep", new_callable=AsyncMock) as mock_sleep, + patch.object(manager, "get_service_for_plug", new_callable=AsyncMock) as mock_get_svc, + ): + # Cool enough to trip the threshold, but a print is active. + mock_pm.get_status.return_value = MagicMock(state="PREPARE", temperatures={"nozzle": 30}) + mock_pm.is_print_active.return_value = True + # Break the poll loop after the first deferral so the test terminates. + mock_sleep.side_effect = asyncio.CancelledError() + + await manager._temp_based_off(1, "tasmota", "1.2.3.4", None, None, None, printer_id=1, temp_threshold=70) + + mock_get_svc.assert_not_called() # never turned off despite temp < threshold + + @pytest.mark.asyncio + async def test_temp_based_off_powers_off_when_cool_and_idle(self, manager): + """Cool nozzle + idle printer → turn off using the plug's threshold.""" + mock_service = AsyncMock() + mock_service.turn_off = AsyncMock(return_value=True) + with ( + patch("backend.app.services.smart_plug_manager.printer_manager") as mock_pm, + patch("backend.app.services.smart_plug_manager.asyncio.sleep", new_callable=AsyncMock), + patch.object(manager, "get_service_for_plug", new_callable=AsyncMock, return_value=mock_service), + patch.object(manager, "_mark_auto_off_executed", new_callable=AsyncMock), + ): + mock_pm.get_status.return_value = MagicMock(state="FINISH", temperatures={"nozzle": 40}) + mock_pm.is_print_active.return_value = False + + await manager._temp_based_off(1, "tasmota", "1.2.3.4", None, None, None, printer_id=1, temp_threshold=55) + + mock_service.turn_off.assert_awaited_once() + + # ---- schedule_off_after_queue_job (uses plug settings, not hardcoded 50/600) + + @pytest.mark.asyncio + async def test_queue_off_uses_time_mode_regardless_of_global_auto_off(self, manager, mock_plug): + """Queue 'auto off after this job' is a per-job override — it schedules + even when the plug's global auto_off is disabled, and honours the plug's + configured time-delay mode.""" + mock_plug.auto_off = False + mock_plug.off_delay_mode = "time" + mock_plug.off_delay_minutes = 8 + with ( + patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock, return_value=[mock_plug]), + patch.object(manager, "_schedule_delayed_off") as mock_delayed, + patch.object(manager, "_schedule_temp_based_off") as mock_temp, + ): + await manager.schedule_off_after_queue_job(printer_id=1, db=AsyncMock()) + + mock_delayed.assert_called_once_with(mock_plug, 1, 8 * 60) # plug's minutes, not hardcoded + mock_temp.assert_not_called() + + @pytest.mark.asyncio + async def test_queue_off_uses_configured_temp_threshold(self, manager, mock_plug): + """Temperature mode passes the plug's off_temp_threshold, not a hardcoded 50.""" + mock_plug.off_delay_mode = "temperature" + mock_plug.off_temp_threshold = 65 + with ( + patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock, return_value=[mock_plug]), + patch.object(manager, "_schedule_delayed_off") as mock_delayed, + patch.object(manager, "_schedule_temp_based_off") as mock_temp, + ): + await manager.schedule_off_after_queue_job(printer_id=1, db=AsyncMock()) + + mock_temp.assert_called_once_with(mock_plug, 1, 65) + mock_delayed.assert_not_called() + + @pytest.mark.asyncio + async def test_queue_off_skips_disabled_and_ha_script_plugs(self, manager, mock_plug): + """Disabled plugs and HA-script entities are never scheduled.""" + disabled = MagicMock(id=2, name="disabled", enabled=False, plug_type="tasmota", ha_entity_id=None) + ha_script = MagicMock( + id=3, name="ha", enabled=True, plug_type="homeassistant", ha_entity_id="script.printer_off" + ) + with ( + patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock, return_value=[disabled, ha_script]), + patch.object(manager, "_schedule_off_per_mode") as mock_sched, + ): + await manager.schedule_off_after_queue_job(printer_id=1, db=AsyncMock()) + + mock_sched.assert_not_called() + + # ---- on_print_start cancellation gap --------------------------------- + + @pytest.mark.asyncio + async def test_reprint_cancels_pending_off_even_when_auto_on_disabled(self, manager, mock_plug): + """A reprint must abort a scheduled auto-off regardless of auto_on (#1890). + + Previously the cancel lived behind the auto_on gate, so a plug with + auto_on disabled kept its pending off and cut power mid-reprint. + """ + mock_plug.auto_on = False + mock_task = MagicMock() + manager._pending_off[mock_plug.id] = mock_task + with ( + patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock, return_value=[mock_plug]), + patch.object(manager, "_mark_auto_off_pending", new_callable=AsyncMock), + patch("backend.app.services.smart_plug_manager.tasmota_service") as mock_tasmota, + ): + mock_tasmota.turn_on = AsyncMock() + + await manager.on_print_start(printer_id=1, db=AsyncMock()) + + mock_task.cancel.assert_called_once() # cancelled despite auto_on=False + assert mock_plug.id not in manager._pending_off + mock_tasmota.turn_on.assert_not_called() # but not powered on