diff --git a/.github/workflows/cleanup-ghcr.yml b/.github/workflows/cleanup-ghcr.yml index 4cc22554a..393af552e 100644 --- a/.github/workflows/cleanup-ghcr.yml +++ b/.github/workflows/cleanup-ghcr.yml @@ -31,7 +31,7 @@ jobs: strategy: fail-fast: false matrix: - package: [bambuddy, bambuddy-beta] + package: [bambuddy] steps: - name: Cleanup ${{ matrix.package }} env: diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d5440030..83696197e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,8 @@ All notable changes to Bambuddy will be documented in this file. - **Push notification for "Printer offline" now actually fires (#1752, reported by @saint-hh)** — The notification provider's `on_printer_offline` toggle has shipped since the notifications feature landed: schema field, DB column, `notification_template.py` entry, and the dispatcher `NotificationService.on_printer_offline(printer_id, printer_name, db)` are all in place. What was missing was the caller — nothing in the codebase actually invoked the dispatcher when a printer went offline. The reporter (P2S, smart-plug-cuts-power scenario) confirmed turning the toggle on did nothing; only the print-failure notification fired when power was restored, via the firmware's `gcode_state=FAILED` report on MQTT reconnect. **Why the toggle was orphan:** every other provider event (`on_print_start`, `on_print_complete`, `on_print_progress`, `on_printer_error`, etc.) has a clear call site under `main.py::on_printer_status_change` or alongside the print-lifecycle hooks. The offline event was the only edge-triggered toggle without one — the dispatcher and template predated the wiring step and were silently shipped. Both upstream offline-trigger paths (`smart_plug_manager` → `printer_manager.mark_printer_offline()` and `bambu_mqtt.py::check_staleness` after the 30s STALE_RECONNECT_COOLDOWN) route through `_on_status_change` already and reach `on_printer_status_change`; the handler just didn't act on the disconnect edge. **Fix:** edge detection in `on_printer_status_change` watches `state.connected` against the previous observation per printer (`_printer_last_connected: dict[int, bool]`). On the True → False transition it schedules `_maybe_notify_printer_offline(printer_id)` as a background asyncio task; on the next True observation it cancels any pending task. The helper sleeps `_PRINTER_OFFLINE_NOTIFY_DEBOUNCE_SECONDS = 60.0` then re-checks `printer_manager.is_connected(printer_id)` — only fires the notification if the printer is still offline. **Why 60s debounce:** sized against `bambu_mqtt.py::STALE_RECONNECT_COOLDOWN = 30s` — a single stale-trigger + reconnect cycle isn't enough to fire, only a real outage that survives one full cooldown notifies. Transient MQTT blips (WiFi roam, broker reload, brief packet loss) recover within the window and the cancellation path kicks in. **Edge-case handling:** initial observation with no prior connected state doesn't fire (covers Bambuddy startup with an already-offline printer); a False → False repeat doesn't reschedule (the in-flight task stays in place rather than resetting the clock on every status callback, which would otherwise mean the notification never fires); the task entry pops from `_printer_offline_notify_tasks` in the finally block whether the notification fired, the printer reconnected, or the task was cancelled mid-await. **No symmetric `on_printer_online` event:** the reporter explicitly noted the "printer lost power and interrupted the print" notification already fires when power is restored — that's the print-failure notification, triggered by the firmware reporting `gcode_state=FAILED` for the interrupted print on MQTT reconnect. That covers the "printer is back" channel without a new toggle. If the user then resumes the print, no print_start notification fires (Bambuddy's `bambu_mqtt.py:3039` explicitly suppresses `is_new_print` for PAUSE → RUNNING to prevent duplicates when resuming from pause), but that's a separate scope from offline-detection. **Tests:** 9 new cases in `test_printer_offline_notification.py` split across two classes. `TestMaybeNotifyPrinterOffline` pins the debounced helper: fires notification when still offline at end of window, doesn't fire when printer reconnected during debounce, doesn't fire when the printer disappeared from the DB (uninstall mid-window), clears `_printer_offline_notify_tasks[printer_id]` after run. `TestOfflineEdgeDetection` pins the edge logic inside `on_printer_status_change`: first observation (connected) doesn't schedule, first observation (disconnected) doesn't schedule (the no-prior-True case — important for startup), True → False schedules a task, reconnect cancels the pending task, repeated False observations don't replace the in-flight task. Full backend suite still green; ruff clean. +- **PrintModal printer picker no longer offers a printer between dispatch-accept and PRINT_START (reported off-list by a corporate user running multi-operator farm shifts)** — Operator picks a printer in the reprint modal, hits Send, Bambuddy accepts the dispatch and begins FTP upload + sending the print command. The printer hasn't reported `gcode_state=RUNNING` yet — it's still IDLE on its own MQTT status. A second operator opening the modal during this window sees the same printer as available and submits a second job. The backend correctly rejects the second submit with HTTP 409 (`background_dispatch._dispatch` rejects when `_queued_jobs` or `_active_jobs` already holds the printer_id), so no double-print is possible, but the operator only finds out after they click Send — wasted minutes per attempt on a busy floor. **Root cause:** `PrinterSelector.tsx::isPrinterBusy` consulted only `PrinterStatus.state` against `AVAILABLE_STATES = {IDLE, FINISH, FAILED}`. PRINT_START is the only signal that flips the printer out of IDLE, and there's a real wall-clock window (upload time + print command + firmware ack) between dispatch acceptance and that flip. The dispatch-queue state — already broadcast as a WebSocket `background_dispatch` push including `dispatched_jobs[].printer_id` and `active_jobs[].printer_id` — was being consumed by `ToastContext` for the progress overlay but never read by the picker. **Fix:** new `frontend/src/hooks/useDispatchedPrinterIds.ts` exposes `Set` of printer_ids with a queued or active dispatch, populated from the same `background-dispatch` window event the ToastContext listens for. Module-level singleton + `useSyncExternalStore` so every `PrinterSelector` instance sees the same snapshot and a modal opened mid-batch picks up the latest state without a refetch. Reference-stable snapshot (size + membership check) keeps `useSyncExternalStore`'s Object.is comparison from re-rendering on every WS push that doesn't change the set. `PrinterSelector.tsx::isPrinterBusy` ORs the set into the existing connected/state check — printer disabled the instant dispatch is accepted, re-enabled when the dispatch finishes (or fails) and disappears from the next state payload. `getPrinterStateLabel` returns `"Dispatching..."` for the badge so operators see the in-flight state instead of a misleading "Idle" on a now-disabled card. Hardcoded English label is consistent with the existing labels in that function (`"Idle"`, `"Printing"`, `"Paused"` are all hardcoded, no i18n key). **What this is NOT:** a backend change (the reservation Mike asked about already exists at `background_dispatch.py:283-290`); a behaviour change for `add-to-queue` / `edit-queue-item` modes (those don't set `disableBusy=true`, so the busy-OR remains dormant for the card click handler — the badge label still flips, which is informative); a guarantee against the WS-not-yet-connected race (a fresh page load that opens the modal before the WS initial-state push lands still sees an empty set for ~1 frame; same race as today, much shorter window). **Tests:** 8 new cases in `useDispatchedPrinterIds.test.ts` pin the contract — empty initial set, picks up `dispatched_jobs` printer_ids, picks up `active_jobs` printer_ids, unions both lists, clears when subsequent event reports zero jobs, ignores non-numeric `printer_id` (defensive against payload drift), reference-stable snapshot when content doesn't change, shared state across hook instances. Existing 84 PrintModal + PrinterSelector cases still green — the new code path is dormant until a `background-dispatch` window event fires, which existing tests don't trigger. `npm run build` clean, ESLint clean. + - **Completion notification reported the whole project's duration and material usage when only one plate of a multi-plate 3MF was printed (#1785)** — Reporter (H2D) noticed the Discord on-print-complete message stated the full multi-plate project's totals (e.g. "6h 12m" / "370 g") even though only a single plate had been started, while every Bambuddy surface (print queue card, archive card, statistics) correctly showed the per-plate values. Root cause traced to the 3MF parser at `services/archive.py:200-264`, which sums per-plate `prediction` (slicer time estimate) and `weight` across every `` of a multi-plate file and stores those file-level totals on the `PrintArchive.print_time_seconds` / `PrintArchive.filament_used_grams` columns. That summing was added by #1593 to fix the archive card under-reporting on multi-plate files, and is correct for the archive-level "whole project" headline. The queue UI already re-reads the 3MF per-plate at `print_queue.py:272-285` (using `extract_filament_usage_from_3mf` / `_extract_print_time_from_3mf`) and substitutes the plate's actual values — which is why everything inside Bambuddy displays per-plate correctly. The notification path at `main.py::_background_notifications` read the archive's columns and `extra_data.filament_slots` directly with **no plate-aware override**, so the dispatched template variables (`{{duration}}`, `{{filament_grams}}`, `{{filament_details}}`) consistently rendered the project-wide sum. For material grams this was unconditionally wrong on multi-plate single-plate prints; for duration it depended on whether the real elapsed `actual_time_seconds` was populated (the `actual_time_seconds or print_time_seconds` fallback chain only landed on the summed estimate when the timestamps weren't usable). **Fix:** new `_scope_notification_archive_data_to_plate(archive_data, file_path, plate_id, status, progress, base_dir)` helper in `main.py` mirrors what the queue UI does — when `plate_id` is set on the just-completed print, re-read the 3MF, sum the plate's `` entries for the actual grams, read the plate's `` for the estimate, and replace `archive_data["actual_filament_grams"]` + `archive_data["print_time_seconds"]` + `archive_data["filament_slots"]` accordingly. `notify_plate_id` is captured from the existing `_print_plate_ids` register at the same point that already pops it (around `main.py:4183`), so no extra bookkeeping is added to the print-start path — the queue dispatcher and direct-Print path both already register plate_id there. **Partial-print scaling preserved:** the helper applies the same `progress / 100` scale factor to the per-plate grams + per-slot weights as the pre-existing summed-totals branch did, so a 50%-cancelled plate-2 print still reports "half the plate's grams," not the whole plate. **Fail-open on every error path:** missing `plate_id` (single-plate file / archive-only flow), missing `archive.file_path`, the 3MF file having been deleted between print completion and notification firing, a corrupt zip, or a plate index outside the file's range — all return `archive_data` unchanged so the notification still ships with the project-level numbers it would have shown before this fix. Same defensiveness shape as the helper-loaders the queue route relies on. **Hoisted `extract_print_time_from_3mf` into `utils/threemf_tools.py`** so the notification path can reuse the queue UI's logic without importing from a routes module (the route's `_extract_print_time_from_3mf` becomes a one-line alias). Identical signature + return shape, so the queue's existing call sites keep working without changes. **Tests:** 10 new cases. `test_threemf_tools.py::TestExtractPrintTimeFrom3mf` (7 cases): plate-N prediction returned for plate_id=N, first plate when no plate_id passed, None for plate_id outside range, None for unparseable prediction, None for missing slice_info / invalid zip / missing file. `test_notification_plate_scope.py::TestScopeNotificationArchiveDataToPlate` (10 cases): plate-2 of 3 collapses summed 370g/3h into plate's 120g/60min; plate-1 and plate-3 scope correctly; partial-print at progress=50 halves grams + per-slot weights but keeps full slicer estimate; no plate_id / no file_path / missing file / corrupt zip / out-of-range plate_id all return the input unchanged so the notification still sends; single-plate file with plate_id=1 is a clean no-op (the parser's sum already collapses to plate-1's values, no double-scaling). Full backend `pytest -n 30` 4086/4086 in 50s; ruff clean. **Scope clarification:** the archive card / project rollup / statistics surfaces stay on the summed totals (the original #1593 contract) — only the completion notification path now plate-scopes, mirroring the queue card precedent. Print Logs entries continue to use the per-run filament helper (#1378 / #1390) which already reads from `usage_results` + scales by progress, so this fix doesn't touch them. ## [0.2.4.7] - 2026-06-14 diff --git a/frontend/src/__tests__/hooks/useDispatchedPrinterIds.test.ts b/frontend/src/__tests__/hooks/useDispatchedPrinterIds.test.ts new file mode 100644 index 000000000..86f5e4e1c --- /dev/null +++ b/frontend/src/__tests__/hooks/useDispatchedPrinterIds.test.ts @@ -0,0 +1,105 @@ +/** + * Tests for useDispatchedPrinterIds — the hook that exposes printer IDs with + * a queued/active background-dispatch job so PrinterSelector can grey them + * out between dispatch-accepted and the printer's PRINT_START report. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { + useDispatchedPrinterIds, + __resetDispatchedPrinterIdsForTests, +} from '../../hooks/useDispatchedPrinterIds'; + +function fire(detail: Record) { + act(() => { + window.dispatchEvent(new CustomEvent('background-dispatch', { detail })); + }); +} + +describe('useDispatchedPrinterIds', () => { + beforeEach(() => { + __resetDispatchedPrinterIdsForTests(); + }); + + afterEach(() => { + __resetDispatchedPrinterIdsForTests(); + }); + + it('returns an empty set initially', () => { + const { result } = renderHook(() => useDispatchedPrinterIds()); + expect(result.current.size).toBe(0); + }); + + it('picks up printer IDs from dispatched_jobs', () => { + const { result } = renderHook(() => useDispatchedPrinterIds()); + fire({ + dispatched_jobs: [ + { job_id: 1, printer_id: 42, printer_name: 'Farm-A' }, + ], + active_jobs: [], + }); + expect(result.current.has(42)).toBe(true); + expect(result.current.size).toBe(1); + }); + + it('picks up printer IDs from active_jobs', () => { + const { result } = renderHook(() => useDispatchedPrinterIds()); + fire({ + dispatched_jobs: [], + active_jobs: [ + { job_id: 1, printer_id: 7, printer_name: 'Farm-B' }, + ], + }); + expect(result.current.has(7)).toBe(true); + }); + + it('unions both lists', () => { + const { result } = renderHook(() => useDispatchedPrinterIds()); + fire({ + dispatched_jobs: [{ job_id: 1, printer_id: 1 }], + active_jobs: [{ job_id: 2, printer_id: 2 }], + }); + expect(result.current.size).toBe(2); + expect(result.current.has(1)).toBe(true); + expect(result.current.has(2)).toBe(true); + }); + + it('clears printers when subsequent event reports no jobs', () => { + const { result } = renderHook(() => useDispatchedPrinterIds()); + fire({ dispatched_jobs: [{ job_id: 1, printer_id: 9 }], active_jobs: [] }); + expect(result.current.has(9)).toBe(true); + fire({ dispatched_jobs: [], active_jobs: [] }); + expect(result.current.size).toBe(0); + }); + + it('ignores jobs without a numeric printer_id', () => { + const { result } = renderHook(() => useDispatchedPrinterIds()); + fire({ + dispatched_jobs: [ + { job_id: 1, printer_id: 'not-a-number' }, + { job_id: 2 }, + { job_id: 3, printer_id: 5 }, + ], + active_jobs: [], + }); + expect(result.current.size).toBe(1); + expect(result.current.has(5)).toBe(true); + }); + + it('keeps snapshot reference stable when content is unchanged', () => { + const { result } = renderHook(() => useDispatchedPrinterIds()); + fire({ dispatched_jobs: [{ printer_id: 1 }], active_jobs: [] }); + const first = result.current; + fire({ dispatched_jobs: [{ printer_id: 1 }], active_jobs: [] }); + expect(result.current).toBe(first); + }); + + it('shares state across hook instances', () => { + const a = renderHook(() => useDispatchedPrinterIds()); + const b = renderHook(() => useDispatchedPrinterIds()); + fire({ dispatched_jobs: [{ printer_id: 11 }], active_jobs: [] }); + expect(a.result.current.has(11)).toBe(true); + expect(b.result.current.has(11)).toBe(true); + }); +}); diff --git a/frontend/src/components/PrintModal/PrinterSelector.tsx b/frontend/src/components/PrintModal/PrinterSelector.tsx index b88eb8c28..e5dc69517 100644 --- a/frontend/src/components/PrintModal/PrinterSelector.tsx +++ b/frontend/src/components/PrintModal/PrinterSelector.tsx @@ -12,6 +12,7 @@ import { Users, } from 'lucide-react'; import { api, type PrinterStatus } from '../../api/client'; +import { useDispatchedPrinterIds } from '../../hooks/useDispatchedPrinterIds'; import { getColorName } from '../../utils/colors'; import { normalizeColorForCompare, @@ -255,7 +256,14 @@ export function PrinterSelector({ return map; }, [activePrinters, statusQueries]); + // Printers with a queued/active background dispatch — accepted by Bambuddy + // but not yet reflected in PrinterStatus.state (which only flips on + // PRINT_START from the printer itself). Backend rejects double-sends with + // 409 anyway; this just stops the operator from picking them in the modal. + const dispatchedPrinterIds = useDispatchedPrinterIds(); + const isPrinterBusy = (printerId: number): boolean => { + if (dispatchedPrinterIds.has(printerId)) return true; const status = printerStatusMap.get(printerId); if (!status) return false; // Unknown state — don't block if (!status.connected) return true; @@ -263,6 +271,7 @@ export function PrinterSelector({ }; const getPrinterStateLabel = (printerId: number): string | null => { + if (dispatchedPrinterIds.has(printerId)) return 'Dispatching...'; const status = printerStatusMap.get(printerId); if (!status) return null; if (!status.connected) return 'Offline'; diff --git a/frontend/src/hooks/useDispatchedPrinterIds.ts b/frontend/src/hooks/useDispatchedPrinterIds.ts new file mode 100644 index 000000000..50a007bbc --- /dev/null +++ b/frontend/src/hooks/useDispatchedPrinterIds.ts @@ -0,0 +1,85 @@ +/** + * Subscribes to background-dispatch WebSocket events and returns the set of + * printer IDs that currently have a queued or active dispatch job. + * + * Used by PrinterSelector to disable printers between the moment Bambuddy + * accepts a dispatch (FTP upload, print command) and the moment the printer + * itself reports PRINT_START. The backend already rejects double-sends with + * HTTP 409, but the UI gap still let operators pick a printer the server would + * refuse — surfaced by a corporate user running multi-operator farm shifts. + * + * Module-level state + useSyncExternalStore so every PrinterSelector instance + * sees the same snapshot, and component mounts mid-batch pick up the latest + * state without re-fetching. + */ +import { useSyncExternalStore } from 'react'; + +interface DispatchEventJob { + printer_id?: unknown; +} + +interface DispatchEventDetail { + dispatched_jobs?: DispatchEventJob[]; + active_jobs?: DispatchEventJob[]; + total?: number; + dispatched?: number; + processing?: number; +} + +const EMPTY: ReadonlySet = new Set(); +let currentSet: ReadonlySet = EMPTY; +const subscribers = new Set<() => void>(); +let attached = false; + +function recompute(detail: DispatchEventDetail): ReadonlySet { + const next = new Set(); + for (const job of detail.dispatched_jobs ?? []) { + if (typeof job.printer_id === 'number') next.add(job.printer_id); + } + for (const job of detail.active_jobs ?? []) { + if (typeof job.printer_id === 'number') next.add(job.printer_id); + } + return next; +} + +function handleEvent(event: Event) { + const detail = (event as CustomEvent).detail ?? {}; + const next = recompute(detail); + // Keep reference stable when content didn't change — useSyncExternalStore + // compares snapshots via Object.is and re-renders on any new reference. + if (next.size === currentSet.size && [...next].every((id) => currentSet.has(id))) { + return; + } + currentSet = next; + subscribers.forEach((cb) => cb()); +} + +function ensureAttached() { + if (attached || typeof window === 'undefined') return; + window.addEventListener('background-dispatch', handleEvent); + attached = true; +} + +const subscribe = (callback: () => void): (() => void) => { + ensureAttached(); + subscribers.add(callback); + return () => { + subscribers.delete(callback); + }; +}; + +const getSnapshot = (): ReadonlySet => currentSet; + +export function useDispatchedPrinterIds(): ReadonlySet { + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} + +/** Test-only helper — resets the module-level singleton between tests. */ +export function __resetDispatchedPrinterIdsForTests(): void { + currentSet = EMPTY; + subscribers.clear(); + if (attached && typeof window !== 'undefined') { + window.removeEventListener('background-dispatch', handleEvent); + attached = false; + } +}