From fdcc063d9f1b7909d0b92cbdf8e4412611c23ace Mon Sep 17 00:00:00 2001 From: maziggy Date: Tue, 9 Jun 2026 09:53:28 +0200 Subject: [PATCH] fix(k-profile): match by filament_id, surface active profile in Configure Slot (#1688 + #1689) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related bugs in K-profile matching, same root cause. #1688 — spool form's PA-profile suggester (PAProfileSection via isMatchingCalibration in spool-form/utils.ts) matched K-profiles by parsing the profile NAME for material/brand/variant. Spools already store slicer_filament (the slicer preset id) and K-profiles already carry filament_id, but both were ignored — so a user's custom K-profile whose name doesn't agree with the slicer preset got silently dropped from suggestions even when the underlying filament_id was identical. #1689 — ConfigureAmsSlotModal's matchingKProfiles ran the same name-only logic on the slot's selected preset. A spool assigned under "Generic PLA" with a custom K-profile actively bound on the printer landed in the modal as "K profile not assigned, default 0.020 will be used", while the printer-card hover-card correctly showed the active profile. Two paths, only one was filtering by name. Shared root: spool preset ids and K-profile filament_ids look different but are equivalent after normalising. Spools store slicer_filament as the cloud setting_id form ("GFSG98_09" — _09 is the variant suffix, the S infix marks setting_id form); K-profiles store filament_id as the bare form ("GFG98"). Plain === doesn't work; both need normalising. This conversion already existed in the other direction at buildFilamentOptions (filament_id → "GFS" + filament_id.slice(2)), so the inverse toFilamentId helper is just the matching reverse, not new ground. Fix — one shared helper, two surfaces: - spool-form/utils.ts: new exports toFilamentId(id) (drops "_NN" variant suffix and strips the "S" in "GFS", so GFSG98_09 → GFG98) and isGenericFilamentId(id) (flags Bambu's generic GFx99 ids which are shared across many filaments and must NOT id-match — the name fallback handles those correctly). - isMatchingCalibration: gains slicer_filament?: string in formData, tries id-match (with generic exclusion) before the existing name parse. PAProfileSection already passes the full formData so no caller edit needed. Strictly additive precedence. - ConfigureAmsSlotModal.selectedPresetInfo: resolves a filamentId field (toFilamentId(cp.setting_id) for cloud presets, toFilamentId(builtinFilamentId) for builtin; empty for local / orca paths which fall through to name match). - ConfigureAmsSlotModal.matchingKProfiles: id-match check at the top of the per-profile predicate (preferred when both sides agree after normalisation), then the existing name-parse logic, then ALWAYS unshifts the slot's currently-active K-profile by slot_id === slotInfo.caliIdx — gated on activeIdx > 0 (so caliIdx 0/null doesn't leak unrelated profiles in), extruder-matched when slotInfo.extruderId is known. This is Spionkiller01's #1689 patch verbatim with the activeIdx > 0 guard added. SpoolBuddy: both kiosk K-profile surfaces reuse the shared components. SpoolBuddyWriteTagPage renders PAProfileSection; SpoolBuddyAmsPage renders ConfigureAmsSlotModal. Verified — fixes propagate automatically, no kiosk-specific edits. What this does NOT change: spools without slicer_filament, K-profiles without filament_id, and generic GFx99 ids all fall through to the existing name-based matching path. Strictly additive precedence; no input shape that matched under the old logic fails to match under the new. The #1053 cloud-preset PFUS* path is preserved because the toFilamentId regex /^GFS/ doesn't match a "PFU" prefix. --- CHANGELOG.md | 2 + .../components/ConfigureAmsSlotModal.test.tsx | 107 ++ .../spool-form/isMatchingCalibration.test.ts | 162 ++++ .../src/components/ConfigureAmsSlotModal.tsx | 53 +- frontend/src/components/spool-form/utils.ts | 41 +- .../{index-fRNJA0id.js => index-lEQPGYKn.js} | 910 +++++++++--------- static/index.html | 2 +- 7 files changed, 815 insertions(+), 462 deletions(-) create mode 100644 frontend/src/__tests__/components/spool-form/isMatchingCalibration.test.ts rename static/assets/{index-fRNJA0id.js => index-lEQPGYKn.js} (88%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76fd591fe..a1e7d3e78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ All notable changes to Bambuddy will be documented in this file. - **Print Log page: per-row delete (#1687 part 1, reported by @IndividualGhost1905)** — Reporter noted that the existing "Also remove this print from Quick Stats" toggle on archive delete is one-shot: if you tick "keep stats" at delete time, there was no later way to drop the row from /stats; and rows that aren't tied to an archive (errors, aborts, manual entries) had no delete affordance at all. **Fix:** every row in the Archives → Print Log table now has a trash icon next to the filament cell, gated on `archives:delete_own` (own rows) or `archives:delete_all` (any row), matching the archive-delete permission shape. Click → confirm modal → row is gone, and because /archives/stats aggregates over `PrintLogEntry` the filament / time / cost contribution drops out of Quick Stats in the same response cycle. The matching archive (if any) is untouched — the log row is a sibling, not a child. **Backend:** new `DELETE /print-log/{entry_id}` mirrors `delete_archive`'s ownership flow via `require_ownership_permission(ARCHIVES_DELETE_ALL, ARCHIVES_DELETE_OWN)`; owners can drop their own rows, admins can drop any row, missing IDs return 404 rather than 200-silently. **Frontend:** new `deletePrintLogEntry` API helper, per-row mutation that invalidates both `print-log` and `archives-stats` query keys so the totals re-render without a manual refresh. **i18n:** 4 new keys (`deleteEntryTitle`, `deleteEntryConfirm`, `entryDeleted`, `entryDeleteFailed`) translated across all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). **Tests:** 3 backend integration cases — delete drops the row from /stats while keeping the linked archive listed, missing ID returns 404, delete-one does not touch siblings (regression guard against an accidental `delete(PrintLogEntry)` without a `where`). Frontend ArchivesPage / PrintLogModal vitests stay green (31 / 31). i18n parity green (5099 leaves × 11 locales). Issue #1687 also asks for per-row tagging (already covered by `EditArchiveModal`'s tags field) and per-row filament-usage-history edits (deferred — see the issue thread for the reasoning). ### Fixed +- **K-profile matching now prefers filament_id over parsed names — surfaces custom profiles in the spool form AND fixes Configure Slot showing "default 0.020" for an actively-bound K-profile (#1688 + #1689, both reported and diagnosed by @Spionkiller01 with concrete H2C testing; #1689 also reported by @IndividualGhost1905)** — Two related symptoms on different UI surfaces, same root cause. **#1688: spool form's PA-profile suggester** (`frontend/src/components/spool-form/PAProfileSection.tsx` via `isMatchingCalibration` in `spool-form/utils.ts`) only matched K-profiles by parsing the profile *name* for material/brand/variant. Spools already store `slicer_filament` (the slicer preset's id) and K-profiles already carry `filament_id`, but both were ignored — so a user's custom K-profile whose name doesn't agree with the slicer preset's name got silently dropped from the suggestion list even when the underlying filament_id was identical. **#1689: ConfigureAmsSlotModal's K-profile filter** (`matchingKProfiles`) ran the same name-only logic on the slot's selected preset — a spool assigned under "Generic PLA" with a custom K-profile actively bound on the printer landed in the modal as "K profile not assigned, default 0.020 will be used", while the printer-card hover-card correctly showed the active profile. The hover-card and the Configure Slot modal disagreed because they used different lookup paths; the modal's path was the one with the name-parse filter. **The shared root cause: spool preset ids and K-profile filament_ids look different but are equivalent after normalisation.** Spools store `slicer_filament` as the cloud setting_id form ("GFSG98_09" — `_09` is the variant suffix, the "S" infix marks it as a setting_id); K-profiles store `filament_id` as the bare form ("GFG98"). Plain `===` doesn't match; both need normalising first. This conversion already exists *in the other direction* at `buildFilamentOptions` (filament_id → "GFS" + filament_id.slice(2) for setting_id), so the inverse `toFilamentId` helper isn't speculative — it's just the matching reverse. **Fix — one shared helper, two surfaces:** new exports in `frontend/src/components/spool-form/utils.ts` — `toFilamentId(id)` normalises both shapes by dropping the "_NN" variant suffix and stripping the "S" in "GFS" (so both "GFSG98_09" and "GFG98" yield "GFG98"); `isGenericFilamentId(id)` flags Bambu's generic `GFx99` ids (GFL99 = generic PLA, GFG99 = generic PETG, etc.) which are shared across many physical filaments and must NOT id-match (they over-match and obscure brand-specific profiles — name fallback handles those correctly). Then: (1) `isMatchingCalibration` accepts a new `slicer_filament?: string` formData field, tries id-match first (with generic exclusion), falls through to the existing name parse — `PAProfileSection` already passes the full `formData` so no caller edit needed. (2) `ConfigureAmsSlotModal.selectedPresetInfo` now also resolves a `filamentId` (via `toFilamentId(cp.setting_id)` for cloud presets; `toFilamentId(builtinFilamentId)` for builtin; empty for local/orca paths that fall through to name match); `matchingKProfiles` adds the id-match check at the top of the per-profile predicate, then keeps the existing name logic, then *always* unshifts the slot's currently-active K-profile (by `slot_id === slotInfo.caliIdx`, gated on `activeIdx > 0` so caliIdx=0/null doesn't leak unrelated profiles in, and extruder-matched when known) — covers the #1689 case where the spool was bound under a generic preset but the active profile lives under a different filament_id entirely. The "always include active" branch is Spionkiller01's #1689 diff verbatim, gated more tightly. **SpoolBuddy coverage:** both K-profile surfaces in the kiosk UI reuse the shared components — `SpoolBuddyWriteTagPage` renders `` (auto-fixed via `isMatchingCalibration`), `SpoolBuddyAmsPage` renders `` (auto-fixed via `matchingKProfiles`). No kiosk-specific edits required; the shared helpers carry the fixes through. (`SpoolBuddyCalibrationPage` is scale calibration, unrelated; `InventorySpoolInfoCard` is display-only.) **What this does NOT change**: spools without a slicer_filament, K-profiles without a filament_id, and generic GFx99 ids all fall through to the existing name-based matching path — strictly additive precedence, no behaviour change for the name-only cases that already worked. The new id-match never causes a *miss* the old code would have caught. **Tests:** 21 new vitest cases — `isMatchingCalibration.test.ts` (18 cases) pins the `toFilamentId` round-trip in both directions (GFSG98_09 → GFG98 and back is identity-preserving for the cloud→K-profile flow), the generic `GFx99` exclusion, falsy/non-Bambu id pass-through (numeric local-preset id, Orca UUID), and the id-match-wins-over-name behaviour including the spool's reported `"GFSG98_09" ↔ K-profile "GFG98"` real-data scenario. `ConfigureAmsSlotModal.test.tsx` (3 cases) pins the modal-level behaviour: a custom K-profile name surfaces when filament_id matches (#1688 in-modal), the slot's active profile is always included even with no name/id match (#1689), and the `caliIdx == 0` guard prevents unrelated profiles from leaking in via the safety net. Full frontend vitest suite: 2108 / 2108 green. ESLint clean on touched files; frontend build clean. **Credit & dispatch:** @Spionkiller01 diagnosed both issues with concrete data (the `GFSG98_09 ↔ GFG98` normalisation case is theirs), tested both patches live on an H2C, and explicitly offered to PR. Landed verbatim with adjustments (shared helper, tighter active-profile guard) and `Co-Authored-By`. @IndividualGhost1905 also reported #1689 independently and identified its connection to #1688. + - **Tabs no longer go silently zombie after the JWT expires — auth-expiry now redirects to /login on the same tab (#1698, reported by @TCL987, fix patched in reporter's fork)** — Reporter on X1C, Docker install, left a Bambuddy tab open past the 24 h JWT lifetime. After expiry: navigation between pages still worked, but every API request silently failed, leaving the UI looking like every list was empty. A manual refresh was needed to land on `/login`. **Root cause: `AuthContext.user` stays stale after the JWT clears.** When a 401 with a token-invalidating message (`Token has expired`, `Could not validate credentials`, `User not found or inactive`, `Invalid API key`, `API key has expired`) lands in `frontend/src/api/client.ts:154-167`, the handler calls `setAuthToken(null)` to drop the token from sessionStorage / localStorage — but `AuthContext.user` is a React state value that was populated once at mount via `checkAuthStatus()` → `/auth/me`, and `setAuthToken(null)` doesn't reach into AuthContext's React tree. `ProtectedRoute` (`App.tsx:101`) only redirects when `user === null`, so the protected tree keeps rendering, every subsequent request goes out with no Authorization header, the backend 401s, and the UI shows nothing. A page refresh remounts `AuthProvider`, `checkAuthStatus()` finds no token, `setUser(null)` fires, the redirect runs — which is what the reporter ended up doing every 24 h. The 3 other `setAuthToken(null)` call sites all live inside `AuthContext` itself and pair with `setUser(null)` directly, so no cross-module signal was needed for them; the `client.ts:165` site was the only one missing the React-tree notification. **Fix (mirrors the reporter's fork patch deec96d1):** after `setAuthToken(null)` in `client.ts`, dispatch a `window.dispatchEvent(new CustomEvent('auth:expired'))` (guarded on `typeof window !== 'undefined'` for SSR / test safety). `AuthContext`'s mount `useEffect` adds a `window.addEventListener('auth:expired', handleAuthExpired)` listener whose handler calls `setUser(null)` after a `mountedRef.current` guard, and removes the listener in the effect's cleanup so unmount → remount doesn't double-bind. `ProtectedRoute` then sees `user === null` on the next render and runs `` immediately, no manual refresh needed. **What this intentionally does NOT change**: generic `401 Authentication required` responses (without a token-invalidating message) still don't clear the token or fire the event — they're treated as transient timing issues, exactly as `client.ts:155`'s pre-existing comment documents. So a one-off 401 from a race during login won't redirect a working session. Listener cleanup means tests / dev hot-reload don't accumulate handlers. **Tests:** 4 new vitest cases — `client.test.ts` gains "dispatches 'auth:expired' event on 401 with invalid token message" and "does not dispatch 'auth:expired' on 401 with generic auth error" (both use `vi.fn()` listeners on `window` to assert the event fires/doesn't fire). `AuthContext.test.tsx` gains a new `auth:expired event (#1698)` describe block — "clears user when an auth:expired event is dispatched" simulates the login → expiry → event → user-null flow end-to-end via `setAuthToken('valid-token')` (the canonical setter; writing to sessionStorage post-import wouldn't propagate to the module-level `authToken` variable initialised at import time), and "does not crash when the event fires after unmount" pins the `mountedRef` guard so the listener can't trigger a state-update-after-unmount warning. Full frontend vitest suite: 2087 / 2087 green. ESLint clean on touched files. Frontend build clean. **Credit to @TCL987** for diagnosing this and shipping the working fix on their fork before opening the issue. - **Filament usage no longer over-counts when printing one plate from a multi-plate 3MF (#1697, reported by @volodymyr-doba)** — Reporter on P1S printed a single lid (~190 g grey PETG) from `gridfinity-storage-box-5x4x6.gcode.3mf` (a multi-plate file with 5×box + 5×lid plates) and the spool's Usage History recorded 242 g of grey + 31 g of black — the **whole file's** filament total, not the dispatched plate. The print took 5 h 47 m which matches the lid alone, and the queue card correctly previewed 190 g, but the spool got debited for everything. **Root cause: usage tracking parsed the 3MF without a plate filter.** `extract_filament_usage_from_3mf(file_path, plate_id)` in `backend/app/utils/threemf_tools.py` already supports filtering and the queue's pre-flight capacity check at `api/routes/print_queue.py:254/:286` passes `item.plate_id`, but the two completion-time recorders did not: `_track_from_3mf` in `services/usage_tracker.py:907` (internal Filament Inventory) and `store_print_data` in `services/spoolman_tracking.py:223` (Spoolman mode) both called the extractor with no plate_id and summed every plate. Per `feedback_inventory_modes_parity` both modes had to ship in the same drop, AND per the verification pass after the initial implementation: the direct-Print path (`api.reprintArchive` / `api.printLibraryFile` with `plate_id: selectedPlate` in `PrintModal/index.tsx:739/750`) hits the same bug because it never goes through the queue — caught before merge by tracing the frontend dispatch surface end-to-end. **Fix — two complementary captures:** (1) `PrintSession` gains a `plate_id: int | None` field; `on_print_start` queries `PrintQueueItem` for the printer's currently-printing row and records `queue_item.plate_id` onto the session — covers the queue path. (2) `register_expected_print` in `main.py` accepts a new `plate_id` parameter and stores it in a parallel `_print_plate_ids: dict[int, int]` dict (mirror of `_print_ams_mappings`); `background_dispatch.py`'s 2 register sites and `print_scheduler.py`'s 1 register site now pass plate_id (the dispatch already resolved it via `_resolve_plate_id`; reordering the resolve to run before register is a no-op since the resolver is pure). At expected-print promotion, `main.py` injects `_print_plate_ids[archive_id]` into `_active_sessions[printer_id].plate_id` (only when the session has no plate_id yet — queue captures win), mirroring the existing `ams_mapping` injection pattern. The dict drains on `on_print_complete` and on TTL eviction of the matching `_expected_prints` entry — same lifecycle as `_print_ams_mappings`. (3) `_track_from_3mf` accepts a new `plate_id` kwarg, threads it from `session.plate_id`, and passes it to `extract_filament_usage_from_3mf`. (4) `store_print_data` accepts a `plate_id` kwarg; the 3 call sites in `main.py` pass `_get_start_plate_id(archive_id)` (new helper, parallel to `_get_start_ams_mapping`); within `store_print_data` the caller value wins, falling back to `queue_item.plate_id` for the queue path. **The PrintArchive's `filament_used_grams` stays file-level summed by design** (#1593's contract — the archive describes the file, not the run); only the per-run usage attribution becomes plate-aware. **What this intentionally does NOT touch:** for direct Print of a single-plate file, `_resolve_plate_id` returns 1 → registered as `plate_id=1`, which extracts plate 1 = the whole file — identical to the prior no-filter behaviour. The change is observable only for multi-plate 3MFs where a specific non-first plate was dispatched. **Tests:** 9 new across `test_usage_tracker.py` + `test_spoolman_tracking.py` + `test_print_start_expected_promotion.py` — plate_id propagation through `_track_from_3mf`; absence leaves it `None`; on_print_start captures queue_item.plate_id; on_print_start no-op when no queue item; Spoolman-mode plate-scoped extract; `register_expected_print` stores `plate_id` in `_print_plate_ids`; `_get_start_plate_id` reads it back; injection into session for direct-Print (no queue capture); guarded against overwriting an already-captured queue plate_id. The pre-existing `test_prefers_explicit_ams_mapping_over_queue_mapping` updated for the new unconditional queue lookup (was conditional, now always queries to capture plate_id). Full 5830-test backend suite green. Ruff clean across the entire backend, not just touched files. diff --git a/frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx b/frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx index ab032146d..4ed73e453 100644 --- a/frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx +++ b/frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx @@ -424,4 +424,111 @@ describe('ConfigureAmsSlotModal', () => { expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Reset Slot/i })).toBeInTheDocument(); }); + + it('surfaces a K-profile whose name does not match the preset when filament_id agrees (#1688)', async () => { + // Spool was edited with slicer_filament = "GFSL05_09" (the setting_id form + // for Bambu PLA Basic). The printer has a *custom* K-profile saved on the + // same filament_id, but the user named it something that doesn't include + // "PLA". Pre-fix, the name-only filter dropped it; the id-match path now + // surfaces it because both sides normalise to "GFL05". + (api.getKProfiles as ReturnType).mockResolvedValue({ + profiles: [ + { + slot_id: 3, + extruder_id: 0, + nozzle_id: 'HH00-0.4', + nozzle_diameter: '0.4', + filament_id: 'GFL05', + name: 'my-custom-tune', + k_value: '0.025', + n_coef: '0', + ams_id: 0, + tray_id: 0, + setting_id: '', + }, + ], + }); + const slotInfo = { + ...defaultProps.slotInfo, + savedPresetId: 'GFSL05_09', // setting_id form for Bambu PLA Basic + }; + render(); + + await waitFor(() => { + // Renders as an