diff --git a/CHANGELOG.md b/CHANGELOG.md index c6c91f518..b2478ee5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,8 @@ All notable changes to Bambuddy will be documented in this file. - **Copy spool — duplicate any spool's settings into a fresh inventory row in two clicks** ([#1234](https://github.com/maziggy/bambuddy/issues/1234), [PR #1246](https://github.com/maziggy/bambuddy/pull/1246) by @MiguelAngelLV) — Adds a copy button (`Copy` icon) next to the existing edit button on every spool in the inventory page across all three views (table row, card, grouped table inner row). Clicking it opens the existing `SpoolFormModal` pre-filled with every field from the source spool — material, brand, color, slicer preset, label/core/cost, K-profiles, all of it — except `weight_used` which is reset to 0 (since the new spool starts full) and the RFID identity fields (`tag_uid`, `tray_uuid`, `tag_type`, `data_origin`) which aren't part of the form payload anyway, so the new spool is its own physical roll. Save calls `api.createSpool` (or `api.createSpoolmanInventorySpool` in Spoolman mode — both inherit the dispatch routing for free). Closes the long-running gap where users with many near-identical spools (e.g. five 1 kg PETG-CF rolls bought in a single order) had to re-enter every field from scratch on each one. **Implementation shape:** `SpoolFormModalProps.mode: 'create' | 'edit' | 'copy'` (exported as `SpoolFormMode`) replaces the previous `isEditing = !!spool` heuristic — every existing call site in `InventoryPage.tsx` was updated to pass the explicit mode, and the modal's title / submit-button label / weight-reset gate / submit-route branching all key on `mode` directly. The `onCopy` callback is optional on `SpoolCard`, `SpoolTableRow`, and `SpoolTableGroup` (matches the existing `onPrintLabel?` pattern), so the button is conditionally rendered and other consumers of those subcomponents don't get a copy affordance forced on them. Card-view and table-row buttons stop click propagation so clicking copy doesn't also fire the parent row's edit handler. **Quick Add interaction:** the Quick Add toggle is gated `mode === 'create'` (was `!isEditing`), so it stays out of copy mode — otherwise a user could enable Quick Add and bump quantity to N under the singular "Copy Spool" title and silently bulk-create N copies via `bulkCreateMutation`. **i18n:** new `inventory.copySpool` key across all 8 locales (en + de translated, fr/it/ja/pt-BR/zh-CN/zh-TW seeded with English fallback per project flow). **Tests:** 3 new in `SpoolFormModal.test.tsx` (`SpoolFormModal copy mode` describe block — title shows "Copy Spool", save calls `createSpool` not `updateSpool`, `weight_used` reset to 0 in the create payload when copying a spool with non-zero usage), 2 new in `InventoryPageCopyButton.test.tsx` (table-row copy button click → "Copy Spool" heading, cards-view copy button click → same heading after switching view modes) — guards against the three call sites drifting apart. Existing `SpoolFormBulk.test.tsx` and `SpoolFormModal.test.tsx` renders that omitted the `mode` prop were updated with the explicit `mode="create"` so the tightened Quick Add gate doesn't hide the toggle from them. Both `InventoryPageCopyButton.test.tsx` and `InventoryPageDeepLink.test.tsx` gained MSW handlers for the modal's open-time fetches (`/api/v1/cloud/status`, `/api/v1/cloud/local-presets`, `/api/v1/cloud/builtin-filaments`, `/api/v1/inventory/color-catalog`, `/api/v1/inventory/spool-catalog`, `/api/v1/printers/`) — without them MSW passes through to the real network, ECONNREFUSEs, and the rejected fetch resolves after the test environment is torn down, surfacing as a flaky "window is not defined" unhandled rejection in the modal's `setLoadingCloudPresets(false)` finally block (pre-existing flake hit ~1 in 3 full-suite runs at PR head). ### Fixed +- **Archives page didn't auto-refresh when a slicer sent a print to a Virtual Printer — the new card only appeared after switching tabs** ([#1282](https://github.com/maziggy/bambuddy/issues/1282), reported by @kleinwareio) — Real-printer prints broadcast `archive_created` over the WebSocket from `main.py`'s MQTT `print_start` handler, and the Archives page listens for that event in `frontend/src/hooks/useWebSocket.ts:241` to invalidate its react-query cache. The VP file-receive paths in `backend/app/services/virtual_printer/manager.py` (`_archive_file` for immediate mode and `_add_to_print_queue` for queue mode) created the archive and committed it to the DB but never broadcast the event — so the page stayed stale until the user clicked another tab and back, which triggered a refetch on focus. **Fix:** factored a small `_broadcast_archive_created(archive)` helper onto `VirtualPrinterInstance` that imports `ws_manager` lazily (matches the file's existing late-import convention for archive/queue imports) and emits the same `{id, printer_id, filename, print_name, status}` payload shape `main.py` uses. Called from both VP paths immediately after the archive is logged (`_archive_file`) and after the queue item is committed (`_add_to_print_queue`). Broadcast failures are swallowed at debug level so a transient WebSocket issue can't break the file-receive flow. The review mode path (`_queue_file`) is intentionally untouched — it creates a `PendingUpload`, not a `PrintArchive`, and renders on a different page. **Tests:** `test_archive_file_broadcasts_archive_created` and `test_add_to_print_queue_broadcasts_archive_created` patch `ws_manager.send_archive_created` and assert it's called once with the right payload shape. **Affects:** every Bambuddy install using a VP in `immediate` or `print_queue` mode; review mode and proxy mode are unaffected. + - **Virtual Printer wedged the slicer at "Downloading...(0%)" when a user clicked Print (instead of Send) against a non-proxy-mode VP, and blocked the next dispatch with "The printer is busy with another print job"** ([#1280](https://github.com/maziggy/bambuddy/issues/1280), reported by @kleinwareio) — Bambuddy's VP supports two distinct dispatch flows from the slicer: **Send** (file upload only — the path queue / immediate / review modes are designed for) and **Print** (file upload + start-print, intended for proxy mode where there's a real printer behind the VP). The reporter's setup was queue mode but they clicked Print, which is unsupported there. The user-facing symptom was wedging instead of a clean error: the FTP upload completed, the file landed in Bambuddy's queue, but Orca's UI froze at `Downloading...(0%)` and the next attempt was blocked. **Cause:** the VP's simulated state machine, in `backend/app/services/virtual_printer/manager.py::on_file_received`, jumped `PREPARE → IDLE` directly after the FTP upload completed. The Send flow doesn't watch the post-upload state, so Send users never noticed. The Print flow watches the gcode_state cycle expecting `PREPARE → RUNNING → FINISH` and only releases its in-flight-job lock when it sees `FINISH` (or `FAILED`). Going `PREPARE → IDLE` looks to the Print-flow slicer like "printer abandoned my job without confirming completion" → UI keeps the prior job pinned → next dispatch is blocked. `gcode_file_prepare_percent` also stayed at `"0"` for the whole upload window, which is why Orca's "Downloading X%" progress bar never advanced. **Fix:** `on_file_received` now transitions `PREPARE → FINISH` with `prepare_percent="100"` and the just-completed filename. The VP's 1-Hz periodic status push (`mqtt_server.py:363`) broadcasts the new state to every connected slicer within a second, so Orca clears its lock and the next dispatch goes through. The transition is gated to `.3mf` uploads only — auxiliary uploads (printer-side `.gcode` blobs etc.) leave the visible state alone. Treats Print and Send identically in non-proxy modes — Print is now silently handled as "file received, treat as completed" instead of wedging the slicer. Send remains a no-op behavior change because Send doesn't watch the post-upload state. **Tests:** 2 new tests in `backend/tests/unit/services/test_virtual_printer.py` pin (1) the FINISH transition with the correct filename + prepare_percent="100", and (2) the non-3MF guard. Affects every VP mode that isn't proxy (`immediate`, `print_queue`, `review`) on every slicer using the Print flow (BambuStudio + OrcaSlicer in LAN-mode). - **External-spool filament selection silently rolled back: every "Generic PLA" / preset change for the external slot looked applied in the UI but failed on the printer, and the next print threw "no mapping"** ([#1279](https://github.com/maziggy/bambuddy/issues/1279), reported by @kleinwareio) — Repro: P1S, no AMS, vt_tray active. User picks any filament for the external slot via Bambuddy. The UI looked normal, but the printer's MQTT response was `{"command":"ams_filament_setting", "result":"fail", "reason":"error string"}`. The companion `extrusion_cali_sel` command succeeded, so the K-profile stuck but the filament *identity* didn't — and the next print therefore had nothing to map to. **Cause:** `backend/app/services/bambu_mqtt.py::ams_set_filament_setting` encoded the single-external-spool case as `{ams_id: 255, tray_id: 0, slot_id: 0}`. The "LOCAL `tray_id = 0`" comment in the code was a misread of the printer's *response* shape (the printer echoes `tray_id: 0` as the slot-within-virtual-unit, not the slot index used in the *request*). **Verification:** captured BambuStudio → X1C `ams_filament_setting` publish via `mosquitto`-compatible paho-mqtt subscriber on the same broker, BambuStudio set the external slot to a PLA preset, the published REQ was `{ams_id: 255, tray_id: 254, slot_id: 0, tray_info_idx: "P4d64437", tray_color: "F72323FF", tray_type: "PLA", ...}` and the printer's REP returned `result: "success"`. The on-wire convention for `ams_filament_setting` on the external spool is therefore the *global* tray index (`tray_id: 254`), not a local slot number (`tray_id: 0`). **Fix:** `mqtt_tray_id = 254` for the single-external branch in both `ams_set_filament_setting` and `reset_ams_slot` (which shares the convention). The dual-external branch (H2D, `len(vt_tray) > 1`) was **not** in the captured exchange and is left at `mqtt_tray_id = 0` until a Studio → H2D capture confirms the correct value — a regression test pins the current dual-external encoding so any future change to that branch surfaces immediately. **Affected printers:** every printer whose MQTT push reports `vt_tray` as a single-element list — i.e. one external slot. That covers all single-nozzle Bambu printers (P1P, P1S, A1, A1 mini, X1C, X1E) plus dual-nozzle models that use a single external feed (X2D). **Not affected** by this change: H2D / H2C / H2S, which expose two external slots and go through a separate `len(vt_tray) > 1` branch. That branch is preserved at its existing `mqtt_tray_id = 0` encoding because the captured exchange did not cover it; if the same misencoding turns out to affect dual-external too, a Studio → H2D capture will surface the right values and a follow-up patch will land. **Known asymmetry not touched in this PR:** the inline `ams_filament_setting` built by `_probe_developer_mode` (`bambu_mqtt.py:2971-2985`) still hardcodes `tray_id=0`. The probe is robust to this — its detection logic only matches `reason: "verify failed"` so it correctly identifies dev-mode regardless of whether the command itself succeeds — but the two builders should be unified in a follow-up. **Tests:** 5 new tests in `backend/tests/unit/services/test_bambu_mqtt.py::TestAmsFilamentSettingExternalSpoolEncoding` pin the X1C/P1S/A1 single-external fix, `reset_ams_slot` symmetry, regular AMS slot encoding unchanged, AMS-HT slot encoding unchanged, and the explicitly-unverified dual-external encoding (so any future change to the dual branch surfaces in diff review). diff --git a/backend/app/services/virtual_printer/manager.py b/backend/app/services/virtual_printer/manager.py index 7cfaee021..9d8b1c598 100644 --- a/backend/app/services/virtual_printer/manager.py +++ b/backend/app/services/virtual_printer/manager.py @@ -269,6 +269,7 @@ class VirtualPrinterInstance: ) if archive: logger.info("[VP %s] Archived: %s - %s", self.name, archive.id, archive.print_name) + await self._broadcast_archive_created(archive) try: file_path.unlink() except OSError: @@ -437,6 +438,7 @@ class VirtualPrinterInstance: db.add(queue_item) await db.commit() logger.info("[VP %s] Added to queue: %s", self.name, queue_item.id) + await self._broadcast_archive_created(archive) try: file_path.unlink() except OSError: @@ -447,6 +449,28 @@ class VirtualPrinterInstance: except Exception as e: logger.error("Error adding to print queue: %s", e) + async def _broadcast_archive_created(self, archive) -> None: + """Notify connected clients that a new archive exists. + + Real-printer prints get this from main.py's MQTT print_start handler; + VP-uploaded prints need their own broadcast or the Archives page stays + stale until the user switches tabs (#1282). + """ + try: + from backend.app.core.websocket import ws_manager + + await ws_manager.send_archive_created( + { + "id": archive.id, + "printer_id": archive.printer_id, + "filename": archive.filename, + "print_name": archive.print_name, + "status": archive.status, + } + ) + except Exception as e: + logger.debug("[VP %s] archive_created broadcast failed: %s", self.name, e) + @staticmethod def _extract_plate_id(file_path: Path) -> int | None: """Extract plate index from 3MF slice_info.config.""" diff --git a/backend/tests/unit/services/test_virtual_printer.py b/backend/tests/unit/services/test_virtual_printer.py index adfdfe915..b9500622b 100644 --- a/backend/tests/unit/services/test_virtual_printer.py +++ b/backend/tests/unit/services/test_virtual_printer.py @@ -217,6 +217,67 @@ class TestVirtualPrinterInstance: assert "verify_job" not in instance._pending_files + @pytest.mark.asyncio + async def test_archive_file_broadcasts_archive_created(self, tmp_path): + """#1282: VP immediate-mode archives must broadcast archive_created so + the Archives page refreshes without a tab switch. Real-printer prints + get this via main.py's MQTT print_start handler; the VP path used to + skip the broadcast entirely.""" + from backend.app.services.virtual_printer.manager import VirtualPrinterInstance + + mock_db = AsyncMock() + mock_db.commit = AsyncMock() + mock_session_factory = MagicMock() + mock_session_ctx = AsyncMock() + mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) + mock_session_ctx.__aexit__ = AsyncMock(return_value=False) + mock_session_factory.return_value = mock_session_ctx + + inst = VirtualPrinterInstance( + vp_id=30, + name="ImmediateBroadcast", + mode="immediate", + model="C12", + access_code="12345678", + serial_suffix="391800030", + base_dir=tmp_path, + session_factory=mock_session_factory, + ) + + file_path = tmp_path / "test.3mf" + file_path.write_bytes(b"fake3mf") + + mock_archive = MagicMock() + mock_archive.id = 99 + mock_archive.printer_id = None + mock_archive.filename = "test.3mf" + mock_archive.print_name = "test" + mock_archive.status = "archived" + + with ( + patch( + "backend.app.api.routes.settings.get_setting", + new_callable=AsyncMock, + return_value=None, + ), + patch( + "backend.app.services.archive.ArchiveService.archive_print", + new_callable=AsyncMock, + return_value=mock_archive, + ), + patch( + "backend.app.core.websocket.ws_manager.send_archive_created", + new_callable=AsyncMock, + ) as mock_broadcast, + ): + await inst._archive_file(file_path, "192.168.1.100") + + mock_broadcast.assert_awaited_once() + payload = mock_broadcast.await_args.args[0] + assert payload["id"] == 99 + assert payload["filename"] == "test.3mf" + assert payload["status"] == "archived" + # ======================================================================== # Tests for auto_dispatch # ======================================================================== @@ -294,6 +355,68 @@ class TestVirtualPrinterInstance: queue_item = added_items[0] assert queue_item.manual_start is False + @pytest.mark.asyncio + async def test_add_to_print_queue_broadcasts_archive_created(self, tmp_path): + """#1282: VP queue-mode uploads must broadcast archive_created so the + Archives page picks up the new entry live. Pre-fix the page only + refreshed when the user manually switched tabs.""" + from backend.app.services.virtual_printer.manager import VirtualPrinterInstance + + mock_db = AsyncMock() + mock_db.add = MagicMock() + mock_db.commit = AsyncMock() + mock_session_factory = MagicMock() + mock_session_ctx = AsyncMock() + mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) + mock_session_ctx.__aexit__ = AsyncMock(return_value=False) + mock_session_factory.return_value = mock_session_ctx + + inst = VirtualPrinterInstance( + vp_id=31, + name="QueueBroadcast", + mode="print_queue", + model="C12", + access_code="12345678", + serial_suffix="391800031", + auto_dispatch=True, + base_dir=tmp_path, + session_factory=mock_session_factory, + ) + + file_path = tmp_path / "test.3mf" + file_path.write_bytes(b"fake3mf") + + mock_archive = MagicMock() + mock_archive.id = 77 + mock_archive.printer_id = None + mock_archive.filename = "test.3mf" + mock_archive.print_name = "test" + mock_archive.status = "archived" + + with ( + patch( + "backend.app.api.routes.settings.get_setting", + new_callable=AsyncMock, + return_value=None, + ), + patch( + "backend.app.services.archive.ArchiveService.archive_print", + new_callable=AsyncMock, + return_value=mock_archive, + ), + patch( + "backend.app.core.websocket.ws_manager.send_archive_created", + new_callable=AsyncMock, + ) as mock_broadcast, + ): + await inst._add_to_print_queue(file_path, "192.168.1.100") + + mock_broadcast.assert_awaited_once() + payload = mock_broadcast.await_args.args[0] + assert payload["id"] == 77 + assert payload["print_name"] == "test" + assert payload["status"] == "archived" + @pytest.mark.asyncio async def test_add_to_print_queue_with_auto_dispatch_off(self, tmp_path): """Verify queue items have manual_start=True when auto_dispatch=False."""