diff --git a/CHANGELOG.md b/CHANGELOG.md index b1d7c9754..7a490b9ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to Bambuddy will be documented in this file. ## [0.2.5b1] - Unreleased +### Fixed +- **Sidebar entries for Files / Archives / Queue no longer hide from non-admin users with granular read access (#1755, reported by @knifesk)** — The reporter noticed the **File Manager** sidebar entry was hidden for a default Operators user even though the same user could load `/files` directly and the backend API accepted their requests. Root cause is broader than reported: `frontend/src/components/Layout.tsx::navPermissions` mapped `files → 'library:read'`, `archives → 'archives:read'`, `queue → 'queue:read'` — the LEGACY permission flags — but the default Operators group at `backend/app/core/permissions.py:368-380` is seeded with the GRANULAR variants only (`ARCHIVES_READ_OWN.value`, `QUEUE_READ_OWN.value`, `LIBRARY_READ_OWN.value`). The migration path at `backend/app/core/database.py:3034-3041` also flips legacy `*:read` → `*:read_own` on existing non-admin groups. So a non-admin user never holds the legacy permission, `hasPermission('library:read')` returns false, sidebar entry is suppressed — for all three resources, not just Files. Admins get `ALL_PERMISSIONS` which includes the legacy variant, so the sidebar always renders for them, which is why this regression went unnoticed until a real non-admin Operator account landed in #1755. **Fix:** `navPermissions` now accepts `Permission | Permission[]` and the three affected resources list all three tiers (`*:read`, `*:read_own`, `*:read_all`). The `isHidden` check switches on the array type — `some(hasPermission)` for arrays, current behavior for single values. Nothing else in the gate logic changed. `frontend/src/api/client.ts` Permission type extended with the missing granular variants (`archives:read_own`, `archives:read_all`, `queue:read_own`, `queue:read_all`, `library:read_own`, `library:read_all`) — these existed in the backend enum and were already being shipped to the frontend in `/auth/me`, but the TS type didn't declare them so any new code wanting to gate on the granular tier would TypeScript-error. **What this also fixes downstream:** any future feature that needs to gate UI on `*:read_own` / `*:read_all` can now do so without re-adding the same type entries. **Tests:** 5 new cases in `Layout.test.tsx::'Sidebar gate accepts granular read tiers (#1755)'` — Files visible with only `library:read_own`, Files visible with only `library:read_all`, Archives visible with only `archives:read_own`, Queue visible with only `queue:read_own`, and the negative case (`printers:read` only — none of Files / Archives / Queue render). 22/22 Layout vitests green; ESLint clean; `npm run build` clean. No backend change, no DB migration, no new i18n keys. No new permission — just unmasks UI for users who already had backend access. + +- **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. + +## [0.2.4.7] - 2026-06-14 + ### Added - **Bambu Lab A2L support (#1684)** — Internal model code `N9`, serial prefix `26A19` (5 chars, same shape as H2C's late `31B8B`). Capabilities resolved from BambuStudio's `resources/profiles/BBL/machine/Bambu Lab A2L.json` cross-checked against Bambu's official A2L specs page: linear rail, single FDM extruder + integrated cutter/plotter head (the BambuStudio `use_double_extruder_default_texture: true` flag covers the dual TOOL HEADS, not dual filament extrusion — A2L must NOT route AMS to the deputy slot or firmware rejects with 07FF_8012). Specs page also confirms NO Ethernet (Wi-Fi 2.4 GHz 802.11 b/g/n only), `Low-Rate-Kamera` on the chamber-image protocol (port 6000, NOT RTSP:322), no heated chamber. **Registry updates**: `PRINTER_MODEL_MAP` + `PRINTER_MODEL_ID_MAP` + `LINEAR_RAIL_MODELS` in `utils/printer_models.py`; `MODEL_TO_API_KEY` + `API_KEY_TO_DEV_MODEL` + `API_KEY_TO_WIKI_PATH` in `firmware_check.py` (wiki path follows the established `/en/a2l/manual/a2l-firmware-release-history` pattern; the existing 404 handling in `_fetch_all_versions_from_wiki` makes this safe to ship before Bambu publishes the page); `VIRTUAL_PRINTER_MODELS` + `MODEL_SERIAL_PREFIXES` in `virtual_printer/manager.py` (prefix `26A19A` with the same revision-letter padding as X2D's `20P90A`); `MODEL_PRODUCT_NAMES` in `virtual_printer/mqtt_server.py`; `mapModelCode` + Add-Printer / Edit-Printer model dropdowns in `PrintersPage.tsx` (new "A2 Series" optgroup); `mapModelCode` in `SpoolBuddyAmsPage.tsx`. **Camera and dual-nozzle code paths need no edits**: `supports_rtsp()` correctly falls through to chamber-image for A2L because `N9` is neither in the internal-code RTSP set nor does the display name match the X1/X2/H2/P2 prefix tuple; `is_dual_nozzle_model()` correctly returns False because A2L is not in `DUAL_NOZZLE_MODELS`. The cutter/plotter capability surfaces in MQTT push fields Bambuddy doesn't yet model; ignored for v1, will surface as a follow-up only if a real-world A2L bundle reveals a confusing UI state. **Tests**: 12 new cases in `test_printer_models.py::TestA2LModel` pinning every dimension — rod type, model-id round-trip, both ethernet directions, both camera-port directions, the explicit non-dual-nozzle guard (regression guard for the BambuStudio profile flag misread), set membership in `LINEAR_RAIL_MODELS` and exclusion from `CARBON_ROD_MODELS` / `STEEL_ROD_MODELS`. @@ -22,10 +29,6 @@ All notable changes to Bambuddy will be documented in this file. - **VP bridge-synthesised reply trace (#1622 round 3)** — The round-2 cmd.jsonl from shaddowlink's P1S vs H2D capture proves the actual failure mode: on P1S in archive mode the slicer issues `extrusion_cali_set` (push K/n directly) and the printer responds `fail`, on H2D and on the P1S second round the slicer takes the `extrusion_cali_sel` flow (select by `filament_id` / `cali_idx`) and the printer responds `success`. Both flows traverse the bridge cleanly — `ams_filament_setting` round-trips with `result=success` and the cached push_status carries `tray_info_idx=GFA11`, `tray_type=PLA-AERO`, K/n, and `cali_idx=-1` intact. So the bridge is innocent on every layer the dump can see, and the open question becomes: what makes the slicer pick `_set` vs `_sel`? Likely candidates are the `info.get_version` answer Bambuddy synthesises (slicer fingerprints on `sw_ver` / `hw_ver` / `module` to decide its command flow) or the first cached `pushall` response the slicer reads to bootstrap its UI. Round 2 captured neither — the JSONL had `slicer_to_bridge` and `printer_to_slicer` directions but no `bridge_to_slicer` direction for the bridge's own synthesised replies. Same env flag (`BAMBUDDY_VP_DUMP_WIRE=1`) now also appends every bridge-synthesised reply (info.get_version answer, project_file ack, on-demand pushall response) to `/vp_wire/_cmd.jsonl` under direction `bridge_to_slicer`. Capture lives in `mqtt_server.py::_publish_to_report` — the single chokepoint every synthesised reply already passes through — gated on a new `log_event: bool = True` parameter; the 1Hz periodic-push path threads `log_event=False` so the JSONL isn't flooded with ~60 lines/min per VP (snapshot dump already covers cache shape). The on-demand pushall response from `_send_status_report` IS logged because that's the bootstrap-fingerprint reply the slicer reads on first connect. Two additional unit tests in `test_vp_mqtt_bridge.py::TestWireFormat` pin the event-on-default and skip-when-`log_event=False` posture; `test_vp_wire_dump.py` already covers the underlying `append_event` shape and the new direction is documented in `_debug.py`'s docstring. Diagnostic-only — does not change the publish data path; the new param defaults preserve every existing call site's behaviour. ### Fixed -- **Sidebar entries for Files / Archives / Queue no longer hide from non-admin users with granular read access (#1755, reported by @knifesk)** — The reporter noticed the **File Manager** sidebar entry was hidden for a default Operators user even though the same user could load `/files` directly and the backend API accepted their requests. Root cause is broader than reported: `frontend/src/components/Layout.tsx::navPermissions` mapped `files → 'library:read'`, `archives → 'archives:read'`, `queue → 'queue:read'` — the LEGACY permission flags — but the default Operators group at `backend/app/core/permissions.py:368-380` is seeded with the GRANULAR variants only (`ARCHIVES_READ_OWN.value`, `QUEUE_READ_OWN.value`, `LIBRARY_READ_OWN.value`). The migration path at `backend/app/core/database.py:3034-3041` also flips legacy `*:read` → `*:read_own` on existing non-admin groups. So a non-admin user never holds the legacy permission, `hasPermission('library:read')` returns false, sidebar entry is suppressed — for all three resources, not just Files. Admins get `ALL_PERMISSIONS` which includes the legacy variant, so the sidebar always renders for them, which is why this regression went unnoticed until a real non-admin Operator account landed in #1755. **Fix:** `navPermissions` now accepts `Permission | Permission[]` and the three affected resources list all three tiers (`*:read`, `*:read_own`, `*:read_all`). The `isHidden` check switches on the array type — `some(hasPermission)` for arrays, current behavior for single values. Nothing else in the gate logic changed. `frontend/src/api/client.ts` Permission type extended with the missing granular variants (`archives:read_own`, `archives:read_all`, `queue:read_own`, `queue:read_all`, `library:read_own`, `library:read_all`) — these existed in the backend enum and were already being shipped to the frontend in `/auth/me`, but the TS type didn't declare them so any new code wanting to gate on the granular tier would TypeScript-error. **What this also fixes downstream:** any future feature that needs to gate UI on `*:read_own` / `*:read_all` can now do so without re-adding the same type entries. **Tests:** 5 new cases in `Layout.test.tsx::'Sidebar gate accepts granular read tiers (#1755)'` — Files visible with only `library:read_own`, Files visible with only `library:read_all`, Archives visible with only `archives:read_own`, Queue visible with only `queue:read_own`, and the negative case (`printers:read` only — none of Files / Archives / Queue render). 22/22 Layout vitests green; ESLint clean; `npm run build` clean. No backend change, no DB migration, no new i18n keys. No new permission — just unmasks UI for users who already had backend access. - -- **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. - - **Virtual Printer queue mode: multi-plate "Send All" now enqueues one queue item per plate** — BambuStudio / OrcaSlicer's "Send All" packs every plate of the project into a SINGLE 3MF and uploads it with one FTP STOR — `slice_info.config` inside the file carries N `` blocks (one per plate), each with its own `` and its own `Metadata/plate_N.gcode` payload. Previously the VP queue path only ever extracted the FIRST plate's index via `_extract_plate_id` and created exactly ONE PrintQueueItem with that single `plate_id`; plates 2..N silently dropped on the floor. Indistinguishable from the user's perspective from "Send" of a single plate — except they expected 3 items in the queue and got 1, with no log line to explain why. **Confirmed against the wire** on the live H2D-1 Proxy VP: `Cube.gcode.3mf` carrying three `` blocks (indices 1, 2, 3) + three per-plate gcode payloads in the same zip, identical filename whether "Send" or "Send All" was clicked — the only signal of intent is the count of `` blocks inside the file. **Fix:** replaced `_extract_plate_id` (returning `int | None`) with `_extract_plate_ids` (returning `list[int]`). The list contains every `` block's `index` metadata, in order; falls back to `[1]` for files missing `slice_info.config` or with no parseable plates so the single-plate path is preserved. `_add_to_print_queue` now loops over the list — each iteration calls `extract_filament_requirements(file_path, plate_id)` per-plate (the plate-aware path was already there from the #1697 work) and creates a PrintQueueItem with that plate's filament types / overrides, plate-specific position = `MAX(position) + iteration`. Single-plate "Send" hits the loop once → exactly today's behaviour (one queue item, plate_id from the slicer, same archive). Multi-plate "Send All" of a 3-plate file → 3 queue items, plate_id 1/2/3, consecutive positions, all pointing at the same backing archive (one upload = one archive). **What stays the same:** the single archive row per upload (the archive backs the queue items via `archive_id`); the `auto_dispatch=False` / `manual_start=true` posture inherited from the VP config (so multi-plate items still require manual start); the `queue_force_color_match` per-VP toggle (now applies per-plate). **What this also fixed downstream:** the `required_filament_types` / `filament_overrides` JSON on each queue item now reflects THAT plate's filaments, not the file's first plate — so the scheduler's per-printer "Any X" matching dispatches each plate onto a printer with the right colours loaded for THAT plate, not for plate 1's filament set. **Tests:** 1 new regression case in `test_virtual_printer.py::TestVirtualPrinterInstance::test_add_to_print_queue_multi_plate_send_all_enqueues_one_per_plate` — builds a 3-plate 3MF (writes the per-plate `` blocks into `slice_info.config` and the per-plate gcode payloads), runs `_add_to_print_queue`, asserts 3 PrintQueueItems with `plate_id == [1, 2, 3]`, `position == [1, 2, 3]`, shared `archive_id`, all `manual_start=True`. 126 existing single-plate VP tests stay green (loop runs once when input has one plate). Full backend suite 5962/5962 green; ruff clean; frontend untouched. **Live-verified** on the H2D-1 Proxy VP — a Send All of the 3-plate Cube project now produces 3 queue items + 1 archive instead of 1 queue item + 1 archive. - **Archive delete now removes related queue items instead of leaving "cancelled" rows behind** — Previously the soft-delete path (the default — what the trash-can button does) called `_cancel_pending_queue_items`, which only flipped queue rows with `status='pending'` to `status='cancelled'` while leaving every other status alone AND leaving every row in the DB. The Send All multi-plate work above made this much more visible: deleting an archive backed by N queue items now had to clean up N rows, and what users saw instead was N "cancelled" rows lingering in the queue history. **Fix (backend):** replaced `_cancel_pending_queue_items` with `_delete_related_queue_items(db, archive_id) -> int` that DELETEs every queue row where `archive_id = X` regardless of status. Behavior now matches what the hard-delete path already did via the `ON DELETE CASCADE` FK on `print_queue.archive_id` — both paths produce the same end state. Print history lives in `PrintLogEntry` (FK `ON DELETE SET NULL`) and is untouched, so stats / Quick Stats / accuracy bands are preserved across both delete paths. **New guard:** the route at `archives.py::delete_archive` now 409s when any related queue item is currently in `status='printing'` — both soft and hard delete are gated by the same precondition, because deleting the archive while a print is live would strip the dispatcher's metadata trail (filament / plate / ams_mapping) out from under the running print. The 409 surfaces a clear "Stop the print first, then retry" message. **Pre-flight count for the UI:** new endpoint `GET /archives/{id}/delete-impact` returns `{related_queue_items: N, currently_printing: M}` — cheap, single endpoint, not folded into the archive list response so the much larger list endpoint isn't forced to run the same query per row. Frontend ArchivesPage delete-confirm modal queries this when the modal opens (`useQuery({queryKey: ['archive', id, 'delete-impact'], enabled: showDeleteConfirm})`) and renders: an amber warning "**N queue item(s) linked to this archive will also be removed.**" when total > 0 AND printing = 0, OR a red warning "**Cannot delete — M queue item(s) are currently printing. Stop the print first, then retry.**" when printing > 0 (with the confirm button disabled in that case so the user can't bonk the 409 on submit). **ConfirmModal extension:** added optional `confirmDisabled?: boolean` prop. Existing `isLoading` was the only disable knob; this adds an external-precondition path that disables the confirm without the loading spinner. **Tests:** rewrote `test_print_queue_api.py::test_soft_delete_archive_cancels_pending_queue_items` → `test_soft_delete_archive_deletes_all_related_queue_items` to pin the new contract (both pending AND completed rows are gone post-soft-delete). 2 new integration cases in `test_archives_api.py`: `test_delete_archive_blocked_when_related_queue_item_printing` (both soft and hard paths return 409 with "printing" in detail message) + `test_archive_delete_impact_reports_counts` (3 mixed-status related rows + 1 unrelated row → endpoint reports `related_queue_items=3, currently_printing=1`, unrelated row doesn't bleed in). **i18n:** 2 new keys (`archives.modal.deleteQueueItemsWarning`, `archives.modal.deleteBlockedByPrinting`) translated across all 11 locales per `feedback_translate_dont_fallback` — no English fallbacks. **Verification:** full backend suite 5964/5964 green with `-n 30`; ruff clean; ESLint clean; `npm run build` clean; vitest 2118/2118 green; i18n parity 5109 × 11 locales green. No DB migration — the CASCADE FK was already in place; only the helper's semantics changed.