diff --git a/CHANGELOG.md b/CHANGELOG.md index 38af2364b..7083cbe90 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. +- **Chamber-fan badge shown on open-frame Bambu printers that have no chamber fan (reported off-list, screenshot of an A1)** — The Printers page rendered three fan widgets (part cooling / auxiliary / chamber) for every printer unconditionally at `PrintersPage.tsx::fanItems` (around line 3579), reading `cooling_fan_speed` / `big_fan1_speed` / `big_fan2_speed` off the status payload. Open-frame Bambu models (A1, A1 Mini, A2L, P1P) physically have no chamber fan — the firmware reports `big_fan2_speed` as 0 there, so the badge always rendered greyed-out and clicking it would let the user "set" a speed on a fan that doesn't exist. Closed-frame models (X1C / X1 / X1E / X2D / P1S / P2S / H2D / H2D Pro / H2C / H2S) are unaffected. **Fix:** new module-scope `MODELS_WITH_CHAMBER_FAN: ReadonlySet` allowlist near `mapModelCode`; the chamber entry is now spread into `fanItems` only when `MODELS_WITH_CHAMBER_FAN.has(printer.model ?? '')`. Open-frame printers drop to a 2-badge row (part + aux), which is what their hardware actually has. **Why an allowlist (not a denylist):** the existing enclosure-door-badge gate at `PrintersPage.tsx:3314` uses an explicit model list pattern; mirroring it keeps the file's classification convention consistent and means a new Bambu model added to the codebase has to be deliberately added to the chamber-fan set rather than silently inheriting (failure mode: missing widget on a real chambered printer, noticed immediately and trivially fixed) — better than the denylist's failure mode (phantom widget on a new open-frame model, looks correct, silently lies). The set deliberately excludes P1P: open-frame, no chamber fan, even though the door-badge list includes it (separate pre-existing inconsistency, not in scope to fix here). **Tests:** 5 new cases in `PrintersPage.test.tsx::'fan badges'` — hides on A1 Mini, hides on A1, hides on P1P, shows on X1C, shows on P1S. Each constructs a single-printer mock with the model under test plus a status payload that has all three fan speeds populated (53 / 53 / 53), then asserts the `title='Chamber Fan'` element is/isn't in the DOM and that the part-cooling / auxiliary badges still render unchanged so we're not over-filtering. Existing 51 PrintersPage cases still green; `npm run build` clean; ESLint clean. **Scope clarification — what this does NOT change.** The `/api/v1/printers/{id}/fan` backend route still accepts `fan=chamber` requests for all models (the dispatch surface stays uniform, so external automations / MQTT relays don't need a model-aware gate); the chamber-fan icon's behaviour and the `printers.fans.chamber` i18n string are unchanged on chambered models; the chamber-*temperature* widget is not affected (open-frame models' status payload doesn't include a chamber temp field, so it was already absent there). + - **In-app "Install Update" on Windows installer installs failed with "Could not find git executable"** — Reported off-list by a Windows user running the `.exe` installer. Root cause has two layers, and the surfaced error only described the first. **Layer 1 — git not findable on Windows.** `backend/app/api/routes/updates.py::_find_executable("git")` falls through `shutil.which("git")` (the installer doesn't bundle git, fresh Windows installs have no git on PATH) into a fallback list of *Unix-only* paths (`/usr/bin/git`, `/opt/homebrew/bin/git`, `~/.local/bin/git` etc) — none exist on Windows, so the helper returns `None` and the route reports "Could not find git executable. Please ensure git is installed." **Layer 2 — no `.git` directory in the installer payload anyway.** `installers/windows/build.py::stage_backend` copies `backend/` via `shutil.copytree` rather than `git clone`, so even if Git for Windows were installed separately, the next `git fetch` would die on `fatal: not a git repository`. Adding Windows paths to `_find_executable` would only have changed which error message users saw. **Fix — distinct `update_method=windows_installer` instead of stretching the git path.** Same shape as the existing `docker` / `ha_addon` branches: detect the installer install, surface a download link to the release `.exe`, let the user run the installer like every other Windows app (Discord/Spotify model). New `_is_windows_installer_install()` helper returns True iff `sys.platform == "win32"` AND `settings.app_dir / ".git"` is absent — so a Windows developer with a real `git clone` keeps the git path, only installer users hit the new branch. New `_find_windows_installer_asset(release_data)` picks the matching asset out of the release's `assets[]` (prefer the versioned `bambuddy--windows-x64-setup.exe` because daily prereleases only publish that form; fall back to the unversioned `bambuddy-windows-x64-setup.exe` alias when present). `/updates/check` now includes `is_windows_installer: bool`, `update_method: "windows_installer"`, and `installer_download_url` on Windows-installer installs. `/updates/apply` short-circuits with `success: false, is_windows_installer: true` after the existing HA / Docker guards — defense in depth, since the frontend swaps the button so the POST shouldn't even fire on Windows. **Frontend.** `UpdateCheckResult` extended with `is_windows_installer`, `installer_download_url`, and `'windows_installer'` in the `update_method` union. `SettingsPage.tsx` inserts a new branch between the Docker snippet and the "Install Update" button: renders the same Bambu-green primary-button style as an `` pointing at `installer_download_url` (falls back to `release_url` then to the tag page so the link is never broken even if the release uploader is mid-publish). Hardcoded `data, settings and printers are preserved` reassurance in the body text — the installer preserves DATA_DIR by design, but Windows users who hit this UI mid-print would otherwise reasonably hesitate. `applyUpdateMutation.onSuccess` toast guard extended to treat `is_windows_installer` the same as HA / Docker, so a direct API call still surfaces the message instead of swallowing it. **i18n.** Two new keys — `settings.updateViaWindowsInstaller` (body copy) and `settings.downloadWindowsInstaller` (button label, takes `{{version}}`) — translated across all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5216 leaves per locale, no English fallback. **Tests.** Backend: 8 new cases in `test_updates_api.py` — `_is_windows_installer_install` (× 3: true on `.git`-less Windows tmp_path, false when `.git` exists, false off-Windows), `_find_windows_installer_asset` (× 3: prefers versioned, falls back to alias, None when missing), `/updates/apply` rejection for installer installs, `/updates/check` returns `update_method="windows_installer"` + the asset URL. Frontend: 1 new case in `SettingsPage.test.tsx::'shows the installer-download link for Windows installer installs'` asserting the `` is rendered with the correct `href` / `target=_blank` / `rel*=noopener` AND the in-app Update button is suppressed AND the Docker/HA messages don't leak across. All 29 backend updates tests + all 44 SettingsPage frontend tests green; `npm run build` clean; ESLint clean; ruff clean. **Scope — what this does NOT change.** No installer change (no git.exe bundled, no `.git` seeded — those would be heavier fixes that still wouldn't match the user mental model of "double-click installer = update"); no behaviour change for Docker / HA / Linux / macOS / Windows-git-clone installs (the existing `update_method` selection prioritises HA → Docker → Windows-installer → git in that order; only `.git`-less Windows installs reach the new branch); no auto-download or in-app installer launch (UAC + NSSM service stop/start ordering would need a separate elevated helper — out of scope for this fix). - **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. diff --git a/frontend/src/__tests__/pages/PrintersPage.test.tsx b/frontend/src/__tests__/pages/PrintersPage.test.tsx index c5d883f05..bd332aa03 100644 --- a/frontend/src/__tests__/pages/PrintersPage.test.tsx +++ b/frontend/src/__tests__/pages/PrintersPage.test.tsx @@ -243,6 +243,73 @@ describe('PrintersPage', () => { }); }); + describe('fan badges', () => { + // Chamber fan only exists on enclosed Bambu models. Open-frame printers + // (A1, A1 Mini, A2L, P1P) have no chamber fan — the firmware reports + // big_fan2_speed as 0 there and the widget would be dead UI. + const statusWithFans = { + ...mockPrinterStatus, + cooling_fan_speed: 53, + big_fan1_speed: 53, + big_fan2_speed: 53, + }; + + const renderWithPrinter = (printer: typeof mockPrinters[number]) => { + server.use( + http.get('/api/v1/printers/', () => HttpResponse.json([printer])), + http.get('/api/v1/printers/:id/status', () => HttpResponse.json(statusWithFans)), + ); + render(); + }; + + it('hides chamber fan badge on A1 Mini (open-frame, no chamber fan)', async () => { + renderWithPrinter({ ...mockPrinters[0], model: 'A1 Mini' }); + + await waitFor(() => { + // Part-cooling badge confirms the fan row rendered. + expect(screen.getByTitle('Part Cooling Fan')).toBeInTheDocument(); + }); + expect(screen.getByTitle('Auxiliary Fan')).toBeInTheDocument(); + expect(screen.queryByTitle('Chamber Fan')).not.toBeInTheDocument(); + }); + + it('hides chamber fan badge on A1 (open-frame)', async () => { + renderWithPrinter({ ...mockPrinters[0], model: 'A1' }); + + await waitFor(() => { + expect(screen.getByTitle('Part Cooling Fan')).toBeInTheDocument(); + }); + expect(screen.queryByTitle('Chamber Fan')).not.toBeInTheDocument(); + }); + + it('hides chamber fan badge on P1P (open-frame)', async () => { + renderWithPrinter({ ...mockPrinters[0], model: 'P1P' }); + + await waitFor(() => { + expect(screen.getByTitle('Part Cooling Fan')).toBeInTheDocument(); + }); + expect(screen.queryByTitle('Chamber Fan')).not.toBeInTheDocument(); + }); + + it('shows chamber fan badge on X1C (enclosed)', async () => { + renderWithPrinter({ ...mockPrinters[0], model: 'X1C' }); + + await waitFor(() => { + expect(screen.getByTitle('Chamber Fan')).toBeInTheDocument(); + }); + expect(screen.getByTitle('Part Cooling Fan')).toBeInTheDocument(); + expect(screen.getByTitle('Auxiliary Fan')).toBeInTheDocument(); + }); + + it('shows chamber fan badge on P1S (enclosed)', async () => { + renderWithPrinter({ ...mockPrinters[0], model: 'P1S' }); + + await waitFor(() => { + expect(screen.getByTitle('Chamber Fan')).toBeInTheDocument(); + }); + }); + }); + describe('empty state', () => { it('shows empty state when no printers', async () => { server.use( diff --git a/frontend/src/pages/PrintersPage.tsx b/frontend/src/pages/PrintersPage.tsx index 97b765bcd..ff3645303 100644 --- a/frontend/src/pages/PrintersPage.tsx +++ b/frontend/src/pages/PrintersPage.tsx @@ -1465,6 +1465,23 @@ function getStatusDisplay(state: string | null | undefined, stg_cur_name: string } } +// Bambu models that ship with an enclosure chamber fan (firmware field +// `big_fan2_speed`). Open-frame models (A1 / A1 Mini / A2L / P1P) have no +// chamber fan — `big_fan2_speed` is meaningless / always 0 there, so the +// widget is hidden in fanItems instead of rendered greyed-out. +const MODELS_WITH_CHAMBER_FAN: ReadonlySet = new Set([ + 'X1C', + 'X1', + 'X1E', + 'X2D', + 'P1S', + 'P2S', + 'H2D', + 'H2D Pro', + 'H2C', + 'H2S', +]); + // Map SSDP model codes to display names function mapModelCode(ssdpModel: string | null): string { if (!ssdpModel) return ''; @@ -3576,6 +3593,12 @@ function PrinterCard({ const statusControlClass = `relative text-center px-2 py-1.5 bg-bambu-dark rounded-lg flex-1 flex flex-col justify-center items-center transition-colors ${ canUseStatusControls ? 'cursor-pointer hover:bg-bambu-dark-tertiary' : 'cursor-default opacity-80' }`; + // Chamber fan only exists on enclosed Bambu models. Open-frame + // printers (A1, A1 Mini, A2L, P1P) have no chamber fan — showing + // the widget there is at best dead UI and at worst suggests a + // control that does nothing. Mirrors the enclosure-door badge + // gate above. + const hasChamberFan = MODELS_WITH_CHAMBER_FAN.has(printer.model ?? ''); const fanItems = [ { key: 'part', @@ -3591,13 +3614,17 @@ function PrinterCard({ Icon: Wind, activeClass: 'text-blue-400', }, - { - key: 'chamber', - label: t('printers.fans.chamber'), - value: status.big_fan2_speed ?? 0, - Icon: AirVent, - activeClass: 'text-green-400', - }, + ...(hasChamberFan + ? [ + { + key: 'chamber', + label: t('printers.fans.chamber'), + value: status.big_fan2_speed ?? 0, + Icon: AirVent, + activeClass: 'text-green-400', + }, + ] + : []), ]; return (