diff --git a/CHANGELOG.md b/CHANGELOG.md index 44ae33768..56d359485 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,7 +82,7 @@ All notable changes to Bambuddy will be documented in this file. - **"Prefer lowest remaining filament" did not actually pick the lowest spool, and could pick a near-empty spool on printers with AMS Filament Backup disabled (#1766, reported by @biduleman)** — Reporter (P1S) had `prefer_lowest_filament=true` with two identical-brand identical-color spools in the AMS, one with less remaining filament than the other; Bambuddy still picked the first slot every time. Root-cause investigation found TWO separate bugs feeding the one report. **(1) The frontend sort never saw inventory grams.** The backend has a two-tier sort (`_prefer_lowest_sort_key` at `print_scheduler.py:1161`) that puts inventory-bound spools in tier 0 (sorted by `label_weight - weight_used` for internal mode or Spoolman's `remaining_weight` for Spoolman mode) and MQTT-only spools in tier 1 (sorted by the printer's `remain%` field) — but it only runs on queue items dispatched without a pre-set `ams_mapping`. The PrintModal "Print Now" / "Add to Queue" flow pre-computes the mapping client-side and submits it, so the backend uses it as-is and the two-tier sort never fires for this path. The frontend's pre-compute (`useFilamentMapping.ts::computeAmsMapping` + `useFilamentMapping`, `useMultiPrinterFilamentMapping.ts::computeMappingWithOverrides` + `computeMatchDetails`, `amsHelpers.ts::autoMatchFilament`) only sorted by `remain%` and had no notion of inventory grams. For two RFID Bambu spools both reporting `remain=100` (freshly inserted, no recent prints) the sort tied at value 100, the slot-position tie-break favoured the lower slot, and the first slot always won — exactly what the reporter saw. **(2) The sort had no notion of whether the printer could actually USE a near-empty spool.** Without AMS Filament Backup enabled on the printer, the firmware will not switch to a second spool when the picked one runs out — so sorting toward the lowest left prints at risk of running dry mid-job. The user-side preference was completely ignorant of the printer-side capability that makes it safe. **Fix — dispatch-time backend gate.** `_compute_ams_mapping_for_printer` at `print_scheduler.py:867` coerces `prefer_lowest=False` when `status.ams_filament_backup is False`, logs `[prefer-lowest] skipped (AMS Backup OFF on printer %s)` so the decision is visible in support bundles without enabling DEBUG. Tri-state default: `None` (unknown / A1 family) applies the sort, preserving today's behaviour. **Fix — banding-equivalent two-tier frontend sort.** New exported `preferLowestSortKey(f, inventoryByTrayId)` in `amsHelpers.ts` mirrors the backend banding exactly — inventory-bound spools sort to tier 0, MQTT-only to tier 1, with backend-matching slot tie-break (`amsId * 4 + trayId` for regular AMS, `1000 + (amsId - 128) * 4 + trayId` for AMS-HT, `10_000` for external / VT so external always sorts LAST regardless of negative raw `ams_id`). All seven frontend sort sites switched to it: `autoMatchFilament` + 2 sites in `useFilamentMapping.ts::computeAmsMapping` (top-level + nested for non-unique tray_info_idx) + 2 sites in `useFilamentMapping.ts::useFilamentMapping` (the hook variant) + 2 sites in `useMultiPrinterFilamentMapping.ts` (`computeMappingWithOverrides`, `computeMatchDetails`) + `autoConfigurePrinter`. Hook signatures grew an optional `inventoryByTrayId?: Map` param — undefined preserves pre-#1766 behaviour for any caller that hasn't wired it in. The banding tie-break alignment is load-bearing on its own: an earlier draft used `amsId * 4 + trayId` for all slots, which gives `ams_id = -1` (external) a NEGATIVE priority that would have beaten AMS slot 0 — caught during a second-round code audit and fixed before commit. **Fix — frontend backup gate.** New exported `effectivePreferLowest(setting, amsFilamentBackup)` mirrors the backend gate rule (`!setting → false; backup === false → false; otherwise true`). PrintModal computes it for the single-printer flow at `index.tsx:380`; `useMultiPrinterFilamentMapping` computes it per-printer inside the `printerResults.map` (different printers in the same dispatch can have different backup states, so a global flag would be wrong); PrinterSelector's `InlineMappingEditor` and `FilamentMapping.tsx`'s standalone editor (the per-AMS slot dropdown) both wired through. The standalone editor previously had NO `preferLowest` awareness at all — its auto-suggestion could disagree with what would actually be dispatched. Closed in this change. **Inventory map — single source of truth.** New `GET /printers/{id}/inventory-remain` endpoint (see Added entry above) exposes the same `_build_inventory_remain_overrides` result the dispatcher uses, so PrintModal and `FilamentMapping.tsx` get the same `Map` the backend would compute. Internal AND Spoolman modes both work uniformly via the existing scheduler helper's branch — external / VT slots excluded, negative grams clamped. Frontend fetches per selected printer via `useQueries` keyed on `'printer-inventory-remain'`, 30 s staleTime, no fetch for unselected printers. An earlier attempt derived the map client-side from `/inventory/assignments` directly — that endpoint only reads the internal-mode `SpoolAssignment` table, so Spoolman users would have silently fallen back to remain%-only sorting. The dedicated endpoint closes that gap. **Settings → Filaments tooltip.** Explanatory note added under the "Prefer lowest remaining filament" toggle description: "Only takes effect when AMS Filament Backup is enabled on the printer — otherwise the printer cannot switch to a second spool when the picked one runs out." Save behaviour unchanged (existing debounced-save fires the "Settings saved" toast). **i18n.** New key `settings.preferLowestFilamentBackupNote` translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW); parity check 5198 leaves per locale, no English fallback. **Tests.** 7 new backend cases — `test_scheduler_backup_gate.py` (tri-state gate × 4: backup OFF coerces, backup ON applies, None preserves today's behaviour, user setting OFF short-circuits regardless of backup) and `test_inventory_remain_endpoint.py` (× 3: no status returns empty map, normal serialisation with string keys, no bindings returns empty). 8 new frontend cases — `useFilamentMapping.test.ts` (`computeAmsMapping` inventory × 3 + `effectivePreferLowest` gate × 5 + slot-priority banding regression × 1) and `PrinterSelector.test.ts` (`autoMatchFilament` inventory × 3). Existing 56 + 27 cases still green — backwards-compat preserved by optional new params. Full backend `pytest -n 30` 6217/6217 in 74s; full frontend `vitest` 2170/2170 in 29s; ruff clean; `npm run build` clean; eslint clean; i18n parity green. -- **H2C nozzle pick from Bambu Studio not preserved on the dual-nozzle rack variant (O1C2) — plus a much wider silent-fallback bug across every Bambu Studio "Send" upload (#1780, reported by @mkoreen)** — The reporter's H2C consistently loaded R2 for HF prints and R4 for standard prints regardless of the nozzle they picked in Bambu Studio. **First-attempt fix (commit d196cfc5) was wrong about the root cause** and didn't take. The real root cause, traced through @mkoreen's `BAMBUDDY_VP_DUMP_WIRE=1` capture + the 0.2.5b1 support bundle on 2026-06-21, is a filename-key mismatch in the VP intake cache that affected EVERY Bambu Studio "Send" upload across EVERY model, not just H2C. **What was actually broken.** `mqtt_server.py:1296` was passing the slicer's bare `subtask_name` (e.g. `Filament_Track_Switch_Holder`, no extension) into the `on_print_command(filename, data)` callback. `on_print_command` then stashed the slicer's print options under that bare name. But `_add_to_print_queue` looked up the cache under `file_path.name` — the FTP filename WITH extension (e.g. `Filament_Track_Switch_Holder.gcode.3mf`). The two strings never matched. The initial `pop` returned None, the 2-second event wait fired against a key that the stash-side never signaled, the wait timed out, and every captured slicer field silently fell back to settings defaults. The user-visible blast radius: not just H2C's `nozzle_mapping` (which was added speculatively in d196cfc5 — see below), but ALSO `bed_leveling` / `flow_cali` / `vibration_cali` / `layer_inspect` / `timelapse` from the original #1403 slicer-opts capture have been silently ignored since BambuStudio started putting the bare model name in `subtask_name` and the extended filename in a separate `file` field. The mismatch survived unit tests because the existing fixtures called `on_print_command(file_path.name, {...})` directly with the FTP filename — bypassing the broken `mqtt_server.py` caller. **Fix.** `on_print_command` derives `stash_key = data.get("file") or filename` and uses THAT for both `_slicer_print_options[...]` and the event-signal lookup. `filename` (subtask_name) still flows to `_schedule_finish_release` unchanged — push_status echoes it back to the slicer as `gcode_file` / `subtask_name` and the slicer matches against its own local subtask_name there, so changing what that path receives would have introduced its own regression (caught and reverted mid-audit). The new derivation falls back to `filename` when `data["file"]` is absent (legacy slicers / non-3MF uploads), so the change is strictly an improvement or a no-op — never a regression. **H2C nozzle_mapping rides through correctly now.** The `nozzle_mapping` array BambuStudio writes into `project_file.print` at the top level (verified via wire capture: 32-entry `list[int]` matching the printer's `get_auto_nozzle_mapping` reply) now reaches the queue item and is replayed on the dispatched `project_file` command via `bambu_mqtt.py::start_print(nozzle_mapping=…)`, gated by the `is_dual_nozzle` flag at runtime. Nullable TEXT column `nozzle_mapping` on `print_queue` — non-branched ALTER matching the `ams_mapping` / `filament_overrides` precedent. The dispatcher parses the JSON-string column back to a `list[int]` on the wire so the shape matches BS exactly. Fail-open on malformed JSON: unparseable column logs a WARNING and omits the field — firmware then runs its auto-pick, never worse than pre-fix. **`nozzles_info` is dead code, dropped.** The original #1780 fix also captured a `nozzles_info` field that was a best-guess based on the OrcaSlicer source. The wire capture confirmed BambuStudio never actually sends it — the field doesn't exist on the project_file body for H2C. Captured-but-unused code removed from `manager.py` intake, `bambu_mqtt.py` dispatch + signature, `printer_manager.py` kwarg, `print_scheduler.py` call, `PrintQueueItemUpdate` + `PrintQueueItemResponse` schemas, and the `print_queue.py` route's parse / serialise paths. The DB column is kept nullable so old rows still load — nothing reads or writes to it anymore. **Diagnostic log added.** `_add_to_print_queue` now logs at DEBUG when slicer_opts is None at the end of intake, including the looked-up key and the actual cache keys present. A future stash/lookup mismatch will surface in 30 seconds of log reading instead of needing a wire capture to diagnose. **Behaviour change worth flagging.** Users with Bambu Studio-set values for bed-leveling / flow-cali / vibration-cali / layer-inspect / timelapse that differ from the Bambuddy default-workflow settings will see those slicer choices honored now instead of silently overridden. This restores the original #1403 intent — slicer values take precedence, settings defaults are the fallback. **Tests.** 3 new regression cases in `test_virtual_printer.py::TestVPProjectFileStashKey` pin the contract: the FTP filename (from `data["file"]`) MUST be the stash key, the bare subtask_name fallback when `file` is absent, and the wait-event signal must fire under the FTP-filename key even when the callback was called with the bare subtask_name. All 3 would fail under the pre-fix code. Existing nozzle / dispatch tests (`TestStartPrintNozzleMappingDispatch` × 5, `test_virtual_printer.py::TestVirtualPrinterInstance` × 3) updated to drop the now-removed `nozzles_info` assertions. `test_printer_manager.py::test_start_print_calls_client` updated for the dropped kwarg. Full backend `pytest -n 30` 6274/6274 in 92.65s; ruff clean; `npm run build` clean; vitest 2156/2156 + QueuePage 26/26 + FileManagerPage 51/51 green; i18n parity green. **Scope.** The stash-key fix affects every queue-mode Virtual Printer regardless of model. The H2C-specific nozzle_mapping pass-through is the user-visible surface that motivated finding the bug. H2D / X2D dual-extruder routing was never affected — those use `ams_mapping2` (ams_id 254/255), already forwarded correctly. No DB migration changes (the `nozzles_info` column stays on disk, just unused), no permission change, no i18n keys, no frontend changes. +- **H2C nozzle pick from Bambu Studio not preserved on the dual-nozzle rack variant (O1C2) — plus a much wider silent-fallback bug across every Bambu Studio "Send" upload (#1780, reported by @mkoreen)** — The reporter's H2C consistently loaded R2 for HF prints and R4 for standard prints regardless of the nozzle they picked in Bambu Studio. **First-attempt fix (commit d196cfc5) was wrong about the root cause** and didn't take. The real root cause, traced through @mkoreen's `BAMBUDDY_VP_DUMP_WIRE=1` capture + the 0.2.5b1 support bundle on 2026-06-21, is a filename-key mismatch in the VP intake cache that affected EVERY Bambu Studio "Send" upload across EVERY model, not just H2C. **What was actually broken.** `mqtt_server.py:1296` was passing the slicer's bare `subtask_name` (e.g. `Filament_Track_Switch_Holder`, no extension) into the `on_print_command(filename, data)` callback. `on_print_command` then stashed the slicer's print options under that bare name. But `_add_to_print_queue` looked up the cache under `file_path.name` — the FTP filename WITH extension (e.g. `Filament_Track_Switch_Holder.gcode.3mf`). The two strings never matched. The initial `pop` returned None, the 2-second event wait fired against a key that the stash-side never signaled, the wait timed out, and every captured slicer field silently fell back to settings defaults. The user-visible blast radius: not just H2C's `nozzle_mapping` (which was added speculatively in d196cfc5 — see below), but ALSO `bed_leveling` / `flow_cali` / `vibration_cali` / `layer_inspect` / `timelapse` from the original #1403 slicer-opts capture have been silently ignored since BambuStudio started putting the bare model name in `subtask_name` and the extended filename in a separate `file` field. The mismatch survived unit tests because the existing fixtures called `on_print_command(file_path.name, {...})` directly with the FTP filename — bypassing the broken `mqtt_server.py` caller. **Fix.** `on_print_command` derives `stash_key = data.get("file") or filename` and uses THAT for both `_slicer_print_options[...]` and the event-signal lookup. `filename` (subtask_name) still flows to `_schedule_finish_release` unchanged — push_status echoes it back to the slicer as `gcode_file` / `subtask_name` and the slicer matches against its own local subtask_name there, so changing what that path receives would have introduced its own regression (caught and reverted mid-audit). The new derivation falls back to `filename` when `data["file"]` is absent (legacy slicers / non-3MF uploads), so the change is strictly an improvement or a no-op — never a regression. **H2C nozzle_mapping rides through correctly now.** The `nozzle_mapping` array BambuStudio writes into `project_file.print` at the top level (verified via wire capture: 32-entry `list[int]` matching the printer's `get_auto_nozzle_mapping` reply) now reaches the queue item and is replayed on the dispatched `project_file` command via `bambu_mqtt.py::start_print(nozzle_mapping=…)`, gated by the `is_dual_nozzle` flag at runtime. Nullable TEXT column `nozzle_mapping` on `print_queue` — non-branched ALTER matching the `ams_mapping` / `filament_overrides` precedent. The dispatcher parses the JSON-string column back to a `list[int]` on the wire so the shape matches BS exactly. Fail-open on malformed JSON: unparseable column logs a WARNING and omits the field — firmware then runs its auto-pick, never worse than pre-fix. **`nozzles_info` is dead code, dropped.** The original #1780 fix also captured a `nozzles_info` field that was a best-guess based on the OrcaSlicer source. The wire capture confirmed BambuStudio never actually sends it — the field doesn't exist on the project_file body for H2C. Captured-but-unused code removed from `manager.py` intake, `bambu_mqtt.py` dispatch + signature, `printer_manager.py` kwarg, `print_scheduler.py` call, `PrintQueueItemUpdate` + `PrintQueueItemResponse` schemas, and the `print_queue.py` route's parse / serialise paths. The DB column is kept nullable so old rows still load — nothing reads or writes to it anymore. **Diagnostic log added.** `_add_to_print_queue` now logs at DEBUG when slicer_opts is None at the end of intake, including the looked-up key and the actual cache keys present. A future stash/lookup mismatch will surface in 30 seconds of log reading instead of needing a wire capture to diagnose. **Behaviour change worth flagging.** Users with Bambu Studio-set values for bed-leveling / flow-cali / vibration-cali / layer-inspect / timelapse that differ from the Bambuddy default-workflow settings will see those slicer choices honored now instead of silently overridden. This restores the original #1403 intent — slicer values take precedence, settings defaults are the fallback. **Tests.** 3 new regression cases in `test_virtual_printer.py::TestVPProjectFileStashKey` pin the contract: the FTP filename (from `data["file"]`) MUST be the stash key, the bare subtask_name fallback when `file` is absent, and the wait-event signal must fire under the FTP-filename key even when the callback was called with the bare subtask_name. All 3 would fail under the pre-fix code. Existing nozzle / dispatch tests (`TestStartPrintNozzleMappingDispatch` × 5, `test_virtual_printer.py::TestVirtualPrinterInstance` × 3) updated to drop the now-removed `nozzles_info` assertions. `test_printer_manager.py::test_start_print_calls_client` updated for the dropped kwarg. Full backend `pytest -n 30` 6274/6274 in 92.65s; ruff clean; `npm run build` clean; vitest 2156/2156 + QueuePage 26/26 + FileManagerPage 51/51 green; i18n parity green. **Scope.** The stash-key fix affects every queue-mode Virtual Printer regardless of model. The H2C-specific nozzle_mapping pass-through is the user-visible surface that motivated finding the bug. H2D / X2D dual-extruder routing was never affected — those use `ams_mapping2` (ams_id 254/255), already forwarded correctly. No DB migration changes (the `nozzles_info` column stays on disk, just unused), no permission change, no i18n keys, no frontend changes. **Round 3 (race-window close).** @mkoreen's 2026-06-23 support bundle showed the round-2 stash-key fix worked but the dispatched `project_file` still went out without `nozzle_mapping`. Log timestamps pinned it precisely: FTP upload complete at `00:42:02.509`, `_add_to_print_queue`'s 2.000 s wait_for hit "No slicer options cached" at `00:42:04.509`, BS's MQTT `project_file` arrived at `00:42:04.594` — exactly 85 ms past the deadline. The queue item was already committed with settings defaults; the late stash sat in the cache until eviction. Bambu Studio on wireless / loaded-Pi setups can land its MQTT command well past the 2 s margin — the previous ceiling was tuned for a happy-path latency, not the observed worst case. **Fix.** Two pieces, both in `services/virtual_printer/manager.py`. (1) The wait timeout is now `_SLICER_OPTIONS_WAIT_TIMEOUT = 5.0`, extracted to a module-level constant so future tuning lives in one place. The +3 s headroom only costs anything when the slicer never sends MQTT at all (legacy / non-BambuStudio) — those uploads now wait a one-time +3 s before the queue item appears, which is acceptable for a queue-add. (2) New late-MQTT fallback: `_add_to_print_queue` now records the just-committed queue item IDs (keyed by FTP filename) into a 30 s in-memory dict; `on_print_command` checks that dict when no event consumer is waiting (meaning the wait already timed out) and retroactively UPDATEs the row's `nozzle_mapping` + `bed_levelling` / `flow_cali` / `vibration_cali` / `layer_inspect` / `timelapse` / `use_ams` fields. Gated on `status == 'pending'` — once the scheduler has picked the row up the UPDATE is a no-op to avoid racing the dispatcher. Multi-plate Send All is covered (the dict stores every committed plate's id; the UPDATE WHERE id IN (...) stamps all of them in one call). 30 s TTL evicts stale entries opportunistically on every queue-add so the dict can't grow unbounded across a long-running VP's uptime. (3) Post-commit last-chance pop. A code audit caught a narrow race in (2): MQTT could arrive during ANY await between the initial `_slicer_print_options.pop` and the recent-queue populate — `wait_for` itself, `archive_print`, `db.flush`, `db.commit` — and `on_print_command` would stash data with no event consumer AND no `_recent_queue_items` entry yet, leaving the late stash to die in the FIFO eviction. After populating `_recent_queue_items`, `_add_to_print_queue` now pops `_slicer_print_options[file_path.name]` one last time and routes any hit through `_restamp_recent_queue_item` inline before returning. Cost is a single dict pop in the happy path; the race fires the same UPDATE path as the on_print_command-triggered restamp. **Tests.** 3 new `TestVirtualPrinterInstance` cases: `test_on_print_command_late_mqtt_retroactively_stamps_queue_item` drives `_add_to_print_queue` with `_SLICER_OPTIONS_WAIT_TIMEOUT` patched to 0.05 s so the wait times out cleanly, then fires `on_print_command` and asserts the UPDATE's bound parameters carry the slicer's `nozzle_mapping` + workflow flags + correct column rename for `bed_leveling → bed_levelling`; `test_add_to_print_queue_catches_mqtt_stashed_post_wait_timeout` hijacks `db.commit`'s first call to stash slicer options mid-flight (simulating MQTT arrival during commit yield), then asserts the post-commit pop runs `_restamp` inline so the UPDATE still ships the slicer values; `test_on_print_command_late_mqtt_skips_already_dispatched_item` pre-seeds the recent-queue dict and stubs the eligibility SELECT to return zero rows, asserting the UPDATE is never issued and `commit()` is not awaited (the row is past the safe window). 139/139 `test_virtual_printer.py` green; 4208/4208 across the full backend unit suite. Ruff clean. No DB migration, no schema change, no frontend change. - **Docker installer fails on the default `/opt/bambuddy` path with "Permission denied" (#1774, reported by @jmoore-skild)** — `install/docker-install.sh::create_install_dir` (line 252) ran `mkdir -p "$INSTALL_PATH"` without sudo while `DEFAULT_INSTALL_PATH="/opt/bambuddy"` (line 32) — root-owned on every Linux distro. `set -e` at line 20 then aborted the whole run before docker compose could ever pull the image. Anyone following the documented `curl … | bash` flow as a normal user hit this immediately. The native installer at `install/install.sh:361` already handles the same situation correctly with `sudo mkdir -p` + `sudo chown`; the Docker variant just never got the same treatment. **Why the fix isn't a default-path change:** the contributor's first instinct was to drop the default to `~/bambuddy` since the Docker installer only writes `docker-compose.yml` + `.env` on the host (real app data lives in named volumes), but `install/update.sh:4` and `install/update_macos.sh:4` both default `INSTALL_DIR` to `/opt/bambuddy`, and `install/README.md:274` documents `INSTALL_DIR=/opt/bambuddy sudo ./update.sh` for the update flow — changing the install default without coordinating the update path would silently break self-service updates for anyone following the docs verbatim. The actual gap is the missing privilege escalation in `create_install_dir`, not the default path. **Fix:** `create_install_dir` now tries `mkdir -p "$INSTALL_PATH" 2>/dev/null` first — the cheap no-sudo path covers `--path ~/bambuddy`, `--path /srv/bambuddy`, and any other writable target — and only falls back to `sudo mkdir -p "$INSTALL_PATH"` + `sudo chown -R "$USER:$USER" "$INSTALL_PATH"` when the unprivileged attempt fails. The chown is load-bearing: without it, the script would later try to write `docker-compose.yml` and `.env` into a root-owned dir as the unprivileged invoking user, kicking off a cascade of EACCES failures further down. Idempotent on re-run (the second `mkdir -p` succeeds against the now-owned dir, no second sudo prompt). `set -e` survives the redirected stderr because the `if !` construct is the documented escape from bash's exit-on-error semantics for an expected-failure check. **Smoke-tested all three branches:** writable target → no sudo prompt fires; idempotent re-run → no second sudo prompt; the failing-mkdir-then-fallback path → `set -e` survives intact. **What this does NOT change:** the default install path stays `/opt/bambuddy` for parity with `install.sh` / `update.sh` / the documented update flow; the Windows mirror at `install/docker-install.ps1` already uses `$env:USERPROFILE\bambuddy` (per-user convention on Windows) and is untouched. No docs change required — `install/README.md` and the wiki Docker page (`bambuddy-wiki/docs/getting-started/docker.md`) both still accurately describe the behaviour. - **MakerWorld import/resolve/status fail under API-key auth even when the owner has a Bambu Cloud login (#1777, reported by @Mx772)** — The reporter (working on a browser extension that drives Bambuddy via `X-API-Key`) noticed that `POST /api/v1/makerworld/import` and `POST /api/v1/makerworld/resolve` returned `{"detail":"Downloading files from MakerWorld requires a Bambu Cloud login"}` even when the key's owning user had a valid stored Bambu Cloud session, and the same imports succeeded from the web UI. Root cause is exactly the shape the reporter traced: `require_permission_if_auth_enabled` in `backend/app/core/auth.py:1414` deliberately returns `current_user=None` for API-keyed callers — the comment at line 1408 makes this explicit and points at `cloud.py` for the resolver. The MakerWorld routes never got that resolver wired in, so `_build_service(db, None)` → `get_stored_token(db, None)` → no token → the "requires Bambu Cloud login" branch fires regardless of what the owning account has set up. Same shape #1182 fixed for cloud slicer presets, and the canonical fix for non-`/cloud/*` routes is already in the codebase as `resolve_api_key_cloud_owner` (cloud.py:128-160) — used by `slicer_presets.py:491` and `library.py:3871`. The MakerWorld routes were missing the wire-up. **Fix:** Three routes get the extra `api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner)` parameter — `get_status`, `resolve_url`, `import_instance` — and each resolves `cloud_token_user = current_user or api_key_cloud_owner` before calling `get_stored_token` / `_build_service`. `import_instance` additionally uses `cloud_token_user.id` for the `owner_id` argument to `save_3mf_bytes_to_library` (which translates to `LibraryFile.created_by_id`), so library rows imported via API key are now attributed to the key's owner instead of staying NULL. `/recent-imports` is unchanged — it only uses `current_user` as a permission gate (`_ = current_user`) and never touches the cloud token. The fix preserves fail-closed semantics for keys *without* the `can_access_cloud` flag: `resolve_api_key_cloud_owner` already fences on `api_key.user_id is not None and api_key.can_access_cloud` (cloud.py:158), so a key with only the per-route scope (`can_read_status` / `can_manage_library`) still surfaces the "requires Bambu Cloud login" error path — no new auth gap. **Two scope fields the API key needs:** the per-route scope (`MAKERWORLD_VIEW` → `can_read_status`, `MAKERWORLD_IMPORT` → `can_manage_library` per `_APIKEY_SCOPE_BY_PERMISSION` in `core/auth.py`) AND the orthogonal `can_access_cloud` flag (separate column on the `api_keys` table). The fix doesn't change that surface — it just stops dropping valid `can_access_cloud=True` keys on the floor. **Tests:** 6 new cases in `backend/tests/integration/test_makerworld_apikey_auth.py` pinning the full surface — API key with `can_access_cloud=True` + owner-has-token → `/status` reports `has_cloud_token=True`, `/resolve` builds the service with the owner User (asserted on the `_build_service` mock's call args), `/import` succeeds end-to-end and the resulting `LibraryFile.created_by_id` matches the API-key owner; API key with `can_access_cloud=False` → status still reports `has_cloud_token=False` (no widening) and import-row's `created_by_id` stays NULL; JWT-authenticated parity check confirms the existing user-session flow is unchanged by the added `Depends`. 6/6 new tests green; full backend suite (6157 tests) still green; ruff clean. No frontend change, no DB migration, no new permission, no new dependency. The reporter's browser extension and any other API-keyed Home Assistant / automation integration unblocks immediately on next deploy. diff --git a/backend/app/services/virtual_printer/manager.py b/backend/app/services/virtual_printer/manager.py index e3f040532..7d6cdb751 100644 --- a/backend/app/services/virtual_printer/manager.py +++ b/backend/app/services/virtual_printer/manager.py @@ -6,6 +6,7 @@ bound to its dedicated IP address, regardless of mode. import asyncio import logging +import time from collections.abc import Callable from datetime import datetime, timezone from pathlib import Path @@ -110,6 +111,23 @@ DEFAULT_VIRTUAL_PRINTER_MODEL = "BL-P001" # X1C # the dict fills, so a long-running VP can't leak unbounded state. _SLICER_OPTIONS_CACHE_LIMIT = 128 +# How long ``_add_to_print_queue`` waits for the slicer's MQTT +# ``project_file`` after the FTP upload completes (#1780 round 3). +# Bambu Studio sends FTP first, then MQTT immediately after — but on +# wireless / loaded setups the MQTT command can land 2+ s after FTP, +# which used to time the wait out and silently drop ``nozzle_mapping`` +# + the other slicer-driven flags. The bumped window covers the +# observed worst case in the field; the late-MQTT fallback in +# ``on_print_command`` covers the rest. +_SLICER_OPTIONS_WAIT_TIMEOUT = 5.0 + +# How long ``on_print_command`` will retroactively stamp slicer fields +# onto a recently-committed queue item when the MQTT print command +# arrives after ``_SLICER_OPTIONS_WAIT_TIMEOUT`` expired. Covers +# extra-late MQTT (slow wireless slicer, NIC drop+retry) and the +# scheduler tick interval before dispatch picks the item up. +_RECENT_QUEUE_ITEM_TTL = 30.0 + def _get_serial_for_model(model: str, serial_suffix: str) -> str: """Get serial number for the given model and suffix.""" @@ -200,6 +218,16 @@ class VirtualPrinterInstance: self._slicer_print_options: dict[str, dict] = {} self._slicer_print_options_events: dict[str, asyncio.Event] = {} + # Queue items recently committed by `_add_to_print_queue`, keyed by + # FTP filename. Used by `on_print_command` to retroactively stamp the + # slicer's nozzle_mapping (and the other slicer-driven flags) onto a + # queue item when the MQTT `project_file` arrives after the queue-add + # wait timed out — the #1780 round-3 race. Value is + # (queue_item_ids, monotonic_committed_at); entries older than + # `_RECENT_QUEUE_ITEM_TTL` are evicted opportunistically on each + # queue-add. + self._recent_queue_items: dict[str, tuple[list[int], float]] = {} + # Per-instance services self._proxy: SlicerProxyManager | None = None self._ftp: VirtualPrinterFTPServer | None = None @@ -342,6 +370,114 @@ class VirtualPrinterInstance: event = self._slicer_print_options_events.get(stash_key) if event: event.set() + return + # No consumer waiting: `_add_to_print_queue` either already gave up + # (wait_for timed out) or hasn't started yet (FTP still uploading). + # If a queue item was committed within the last + # `_RECENT_QUEUE_ITEM_TTL`, the wait timed out and the row holds + # settings defaults instead of the slicer's pick — retroactively + # stamp the slicer-driven fields so the dispatcher honours the + # user's choice. Covers the #1780 round-3 race where Bambu Studio's + # MQTT lands just past the bumped wait ceiling. + await self._restamp_recent_queue_item(stash_key, data) + + async def _restamp_recent_queue_item(self, stash_key: str, data: dict) -> None: + """Patch slicer-driven fields onto a queue item the MQTT command missed. + + ``_add_to_print_queue`` waits up to ``_SLICER_OPTIONS_WAIT_TIMEOUT`` + for the slicer's MQTT ``project_file`` before committing the queue + item. If the MQTT command arrives after that window — observed in + the field at ~2.1 s on H2C / wireless setups (#1780 round 3) — the + row was already written with settings defaults. This method runs + on the late MQTT path: it looks up the most recent queue items + committed for this filename and patches in the slicer's + ``nozzle_mapping`` + workflow flags, but only while the items are + still ``pending`` (scheduler hasn't dispatched them yet). + """ + if not self._session_factory: + return + entry = self._recent_queue_items.get(stash_key) + if entry is None: + return + queue_item_ids, committed_at = entry + if time.monotonic() - committed_at > _RECENT_QUEUE_ITEM_TTL: + self._recent_queue_items.pop(stash_key, None) + return + + import json + + # Mirror the field set `_add_to_print_queue` reads off slicer_opts. + # MQTT uses `bed_leveling` (single L); the column is `bed_levelling`. + # `nozzles_info` is intentionally not stamped — column kept for + # legacy rows but never written; see PrintQueueItem.nozzles_info. + patch: dict = {} + for mqtt_field, column in ( + ("bed_leveling", "bed_levelling"), + ("flow_cali", "flow_cali"), + ("vibration_cali", "vibration_cali"), + ("layer_inspect", "layer_inspect"), + ("timelapse", "timelapse"), + ("use_ams", "use_ams"), + ): + if mqtt_field in data: + patch[column] = bool(data[mqtt_field]) + + raw = data.get("nozzle_mapping") + if raw is not None: + if isinstance(raw, str): + try: + raw = json.loads(raw) + except json.JSONDecodeError: + logger.warning( + "[VP %s] Late MQTT nozzle_mapping is unparseable JSON, dropping: %r", + self.name, + raw, + ) + raw = None + if raw is not None: + patch["nozzle_mapping"] = json.dumps(raw) + + if not patch: + self._recent_queue_items.pop(stash_key, None) + return + + from sqlalchemy import select, update + + from backend.app.models.print_queue import PrintQueueItem + + try: + async with self._session_factory() as db: + # Only stamp items still pending; once the scheduler has + # picked the row up we can't safely race the dispatcher. + result = await db.execute( + select(PrintQueueItem.id).where( + PrintQueueItem.id.in_(queue_item_ids), + PrintQueueItem.status == "pending", + ) + ) + eligible_ids = [row[0] for row in result.all()] + if not eligible_ids: + self._recent_queue_items.pop(stash_key, None) + return + await db.execute(update(PrintQueueItem).where(PrintQueueItem.id.in_(eligible_ids)).values(**patch)) + await db.commit() + logger.info( + "[VP %s] Late slicer MQTT for %s — retroactively stamped %s onto queue item(s) %s", + self.name, + stash_key, + sorted(patch.keys()), + eligible_ids, + ) + except Exception as e: + logger.error( + "[VP %s] Failed to retroactively stamp queue item(s) %s for %s: %s", + self.name, + queue_item_ids, + stash_key, + e, + ) + finally: + self._recent_queue_items.pop(stash_key, None) def _schedule_finish_release(self, filename: str, delay: float = 1.5) -> None: """Re-set gcode_state=FINISH on the VP after the project_file ack. @@ -524,19 +660,21 @@ class VirtualPrinterInstance: # queue item can inherit the slicer-side print options the user # picked (timelapse, bed_leveling, etc). Slicers send the FTP upload # first and the MQTT command immediately after, so the typical lag - # is a few hundred ms; 2 s is conservative without making every - # VP-queue add visibly slow. Falls back to the global default_* + # is a few hundred ms. The window is generous enough to absorb + # wireless / loaded-Pi jitter without making every VP-queue add + # visibly slow — observed worst case in #1780 round 3 was 2.085 s, + # the previous 2.0 s ceiling. Falls back to the global default_* # settings if MQTT doesn't arrive in time (legacy behaviour for # users on a slicer that doesn't send a print command). #1403. # The wait is skipped when there's no MQTT server attached — covers # unit tests that invoke `_add_to_print_queue` directly without - # going through `on_print_command`, so they don't pay the 2 s tax. + # going through `on_print_command`, so they don't pay the wait tax. slicer_opts = self._slicer_print_options.pop(file_path.name, None) if slicer_opts is None and self._mqtt is not None: event = asyncio.Event() self._slicer_print_options_events[file_path.name] = event try: - await asyncio.wait_for(event.wait(), timeout=2.0) + await asyncio.wait_for(event.wait(), timeout=_SLICER_OPTIONS_WAIT_TIMEOUT) slicer_opts = self._slicer_print_options.pop(file_path.name, None) except asyncio.TimeoutError: slicer_opts = None @@ -749,6 +887,34 @@ class VirtualPrinterInstance: await db.flush() # populate queue_item.id before logging queue_item_ids.append(queue_item.id) await db.commit() + # Track the freshly-committed queue items so + # `on_print_command` can retroactively stamp slicer-side + # fields if the MQTT `project_file` lands AFTER the + # `_SLICER_OPTIONS_WAIT_TIMEOUT` window expired — the + # #1780 round-3 race. Eviction of stale entries here + # keeps the dict bounded; the queue path is the only + # writer, so doing it on commit is enough. + now = time.monotonic() + cutoff = now - _RECENT_QUEUE_ITEM_TTL + self._recent_queue_items = {k: v for k, v in self._recent_queue_items.items() if v[1] > cutoff} + self._recent_queue_items[file_path.name] = (list(queue_item_ids), now) + # Last-chance check: MQTT for this filename could have + # arrived during ANY await between the initial pop and + # now — wait_for itself, archive_print, db.flush, + # db.commit. In all those cases `on_print_command` + # stashed its data but neither the event-signal path nor + # the retroactive `_recent_queue_items` path was in + # place to consume it. Pop any late stash and apply + # inline so the late MQTT never leaks past the queue-add. + late_opts = self._slicer_print_options.pop(file_path.name, None) + if late_opts is not None: + logger.info( + "[VP %s] Late slicer MQTT detected for %s during queue-add — " + "applying inline (race vs commit/archive/flush yield)", + self.name, + file_path.name, + ) + await self._restamp_recent_queue_item(file_path.name, late_opts) if len(queue_item_ids) == 1: logger.info("[VP %s] Added to queue: %s", self.name, queue_item_ids[0]) else: diff --git a/backend/tests/unit/services/test_virtual_printer.py b/backend/tests/unit/services/test_virtual_printer.py index 2b0f5e79f..19d940723 100644 --- a/backend/tests/unit/services/test_virtual_printer.py +++ b/backend/tests/unit/services/test_virtual_printer.py @@ -1785,6 +1785,272 @@ class TestVirtualPrinterInstance: for item in added_items: assert _json.loads(item.nozzle_mapping) == [16, 0] + @pytest.mark.asyncio + async def test_on_print_command_late_mqtt_retroactively_stamps_queue_item(self, tmp_path): + """#1780 round 3: Bambu Studio's MQTT project_file can arrive AFTER + `_add_to_print_queue` already gave up waiting (observed at 2.085 s + on H2C wireless setups). The queue item was committed with settings + defaults; the slicer's nozzle_mapping + workflow flags must be + patched onto it when MQTT lands, otherwise the H2C firmware falls + back to auto-pick. + """ + import json as _json + + from backend.app.services.virtual_printer.manager import VirtualPrinterInstance + + added_items: list = [] + mock_db = AsyncMock() + mock_db.add = MagicMock( + side_effect=lambda item: (added_items.append(item), setattr(item, "id", 100 + len(added_items)))[0] + ) + + async def _flush(): + # added_items[-1].id was set by `add`; nothing else to do. + return None + + mock_db.flush = AsyncMock(side_effect=_flush) + mock_db.commit = AsyncMock() + + # First execute() call (the position-max SELECT inside _add_to_print_queue) + # returns None; second (the eligible-pending SELECT in + # _restamp_recent_queue_item) returns the committed queue id; third + # (the UPDATE) is fire-and-forget. + position_max_result = MagicMock() + position_max_result.scalar = MagicMock(return_value=None) + select_pending_result = MagicMock() + select_pending_result.all = MagicMock(return_value=[(101,)]) + update_result = MagicMock() + mock_db.execute = AsyncMock(side_effect=[position_max_result, select_pending_result, update_result]) + + 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=99, + name="LateMQTT", + mode="queue", + model="O1C2", + access_code="12345678", + serial_suffix="391800099", + base_dir=tmp_path, + session_factory=mock_session_factory, + ) + # MQTT server presence enables the wait_for path; we don't actually + # use any methods on it. + inst._mqtt = MagicMock() + + file_path = tmp_path / "test.3mf" + file_path.write_bytes(b"fake3mf") + + mock_archive = MagicMock() + mock_archive.id = 1 + mock_archive.print_name = "test" + + # 1. _add_to_print_queue runs WITHOUT a prior on_print_command — + # the wait_for times out (settings-default fallback) and the + # queue item is committed. + 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, + ), + # Shorten the wait so the test isn't slow. + patch( + "backend.app.services.virtual_printer.manager._SLICER_OPTIONS_WAIT_TIMEOUT", + 0.05, + ), + ): + await inst._add_to_print_queue(file_path, "192.168.1.100") + + assert len(added_items) == 1 + assert added_items[0].nozzle_mapping is None # MQTT was never received + assert file_path.name in inst._recent_queue_items + + # 2. MQTT project_file arrives AFTER the wait expired — must + # retroactively patch the queue item. + await inst.on_print_command( + file_path.name, + { + "command": "project_file", + "file": file_path.name, + "nozzle_mapping": [16, -1, -1, 1], + "timelapse": True, + "bed_leveling": False, + }, + ) + + # The UPDATE call is the third execute. Inspect its values. + update_call = mock_db.execute.await_args_list[2] + update_stmt = update_call.args[0] + compiled = update_stmt.compile(compile_kwargs={"literal_binds": False}) + params = dict(compiled.params) + assert _json.loads(params["nozzle_mapping"]) == [16, -1, -1, 1] + assert params["timelapse"] is True + assert params["bed_levelling"] is False # MQTT bed_leveling → column bed_levelling + # Recent-queue tracking dict is cleared after the patch. + assert file_path.name not in inst._recent_queue_items + + @pytest.mark.asyncio + async def test_add_to_print_queue_catches_mqtt_stashed_post_wait_timeout(self, tmp_path): + """The actual race-window scenario: wait_for times out, then MQTT + arrives and stashes options AFTER the wait but BEFORE the + post-commit re-check. The post-commit pop must catch it. + """ + import json as _json + + from backend.app.services.virtual_printer.manager import VirtualPrinterInstance + + added_items: list = [] + mock_db = AsyncMock() + mock_db.add = MagicMock( + side_effect=lambda item: (added_items.append(item), setattr(item, "id", 300 + len(added_items)))[0] + ) + mock_db.flush = AsyncMock() + mock_db.commit = AsyncMock() + + position_max_result = MagicMock() + position_max_result.scalar = MagicMock(return_value=None) + select_pending_result = MagicMock() + select_pending_result.all = MagicMock(return_value=[(301,)]) + update_result = MagicMock() + mock_db.execute = AsyncMock(side_effect=[position_max_result, select_pending_result, update_result]) + + 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=96, + name="RaceCommitYield", + mode="queue", + model="O1C2", + access_code="12345678", + serial_suffix="391800096", + base_dir=tmp_path, + session_factory=mock_session_factory, + ) + inst._mqtt = MagicMock() + + file_path = tmp_path / "test.3mf" + file_path.write_bytes(b"fake3mf") + + # Stash MQTT data on the FIRST commit (simulating MQTT arrival + # during _add_to_print_queue's commit yield); _restamp also calls + # db.commit later, so we one-shot the side effect. + commit_calls = {"n": 0} + + async def _delayed_stash(*_args, **_kwargs): + commit_calls["n"] += 1 + if commit_calls["n"] == 1: + inst._slicer_print_options[file_path.name] = { + "command": "project_file", + "file": file_path.name, + "nozzle_mapping": [0, 16, -1, -1], + "timelapse": False, + } + return None + + mock_db.commit = AsyncMock(side_effect=_delayed_stash) + + mock_archive = MagicMock() + mock_archive.id = 1 + mock_archive.print_name = "test" + + 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.services.virtual_printer.manager._SLICER_OPTIONS_WAIT_TIMEOUT", + 0.05, + ), + ): + await inst._add_to_print_queue(file_path, "192.168.1.100") + + # Queue item INSERTed with defaults (wait timed out, no slicer_opts). + assert len(added_items) == 1 + # But the post-commit pop caught the late stash and applied the + # slicer nozzle_mapping via _restamp's UPDATE. + update_call = mock_db.execute.await_args_list[2] + update_stmt = update_call.args[0] + compiled = update_stmt.compile(compile_kwargs={"literal_binds": False}) + params = dict(compiled.params) + assert _json.loads(params["nozzle_mapping"]) == [0, 16, -1, -1] + assert params["timelapse"] is False + # _recent_queue_items entry was consumed by the post-commit + # _restamp call. + assert file_path.name not in inst._recent_queue_items + # And the stash is empty. + assert file_path.name not in inst._slicer_print_options + + @pytest.mark.asyncio + async def test_on_print_command_late_mqtt_skips_already_dispatched_item(self, tmp_path): + """Once the scheduler has picked the queue item up (status != pending), + the retroactive patch is a no-op — racing the dispatcher would be + unsafe. + """ + from backend.app.services.virtual_printer.manager import VirtualPrinterInstance + + mock_db = AsyncMock() + # The eligible-pending SELECT returns nothing — item is no longer pending. + empty_result = MagicMock() + empty_result.all = MagicMock(return_value=[]) + mock_db.execute = AsyncMock(return_value=empty_result) + 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=98, + name="LateMQTTDispatched", + mode="queue", + model="O1C2", + access_code="12345678", + serial_suffix="391800098", + base_dir=tmp_path, + session_factory=mock_session_factory, + ) + inst._mqtt = MagicMock() + # Pre-seed the recent-queue dict — pretend _add_to_print_queue just + # committed item id 42. + inst._recent_queue_items["test.3mf"] = ([42], 1_000_000.0) + # Drive _restamp via on_print_command on the late-MQTT path. + with patch("backend.app.services.virtual_printer.manager.time.monotonic", return_value=1_000_001.0): + await inst.on_print_command( + "test.3mf", + { + "command": "project_file", + "file": "test.3mf", + "nozzle_mapping": [16, -1], + }, + ) + # No UPDATE was issued — only the eligibility SELECT ran. + assert mock_db.execute.await_count == 1 + mock_db.commit.assert_not_awaited() + assert "test.3mf" not in inst._recent_queue_items + class TestVirtualPrinterManager: """Tests for VirtualPrinterManager orchestrator."""