Single failure on a printer with require_previous_success queue items
permanently skipped every downstream + every new item — the
_check_previous_success lookback always walked back to the original
failed row (skipped is excluded from the lookback), and no code path
could dismiss that failure.
Three pieces:
1. PrintQueueItem.gate_acknowledged Boolean column (default False).
SQLite/Postgres-safe ALTER, dialect-branched DEFAULT.
2. _check_previous_success skips rows where gate_acknowledged=True so
acknowledged failures walk past the lookback. Fresh post-resume
failures still gate independently.
3. POST /api/v1/queue/printer/{printer_id}/resume — gated on
QUEUE_UPDATE_ALL — acknowledges failed/aborted items for that
printer AND restores items where
status='skipped' AND error_message='Previous print failed or was
aborted' back to pending in one transaction. Returns
{acknowledged, restored}.
Frontend banner above the active Queue tab surfaces blocked printers,
fires a warning-variant ConfirmModal, and shows a precise toast on
success.
Banner pointed to bambuddy.cool/wiki/getting-started/... which 404s;
the wiki lives under the wiki.bambuddy.cool subdomain. Anchor was
correct (MkDocs slug matches the existing "Step 4: Enable Store sent
files on external storage" heading).
Bulk operations on the Inventory page in both built-in and Spoolman modes.
Reporter wanted ten-of-the-same-spool edits without ten round-trips through
the per-spool editor.
Frontend
- New checkbox column on the inventory table (header / row / group). Sticky
toolbar appears when at least one row is selected with Edit / Print labels /
Reset usage / Archive (or Restore in the Archived tab) / Delete / Clear.
Selection clears on any filter / tab / search change so the count can't
drift from what is on screen.
- BulkEditSpoolsModal is a three-state-per-field form. The user opts in per
field by ticking its checkbox or just typing into it; only ticked + non-
empty fields are sent. Clearing fields in bulk is intentionally NOT
supported per the issue discussion.
- A new SearchableSelect renders all categorical fields (material, sub-type,
brand, category, slicer preset name, slicer filament, storage location)
with the same dropdown pattern the per-spool editor uses - text input +
chevron + filtered button list, click-outside / Escape closes. No native
select anywhere in the modal. Options merge the canonical constants from
spool-form/constants.ts with whatever already exists in the user's
inventory. Slicer-preset dropdowns fetch the same sources as the per-spool
form (Bambu Cloud + Orca Cloud + local + built-in) through buildFilament
Options() and three useQuery calls gated on isOpen.
- onSuccess handlers surface three outcomes: all-succeeded (green toast),
partial-success (yellow toast with ok / failed counts), all-failed (red
toast that keeps the selection and modal open so the user can retry).
The first cut silently dropped errors / not_found arrays - audited and
fixed before merge.
- Invalid rgba hex is flagged inline with a red border + helper text and
the Apply button is gated on a hasDroppedTickedField guard, so silently
dropping a ticked field is no longer possible.
- bulkResetConsumedCounterMutation.onSuccess now closes the confirm modal +
clears selection, matching the other three bulk mutations.
Backend
- Four new endpoints per inventory mode (eight total):
POST /api/v1/inventory/spools/bulk-update INVENTORY_UPDATE
POST /api/v1/inventory/spools/bulk-delete INVENTORY_UPDATE
POST /api/v1/inventory/spools/bulk-archive INVENTORY_UPDATE
POST /api/v1/inventory/spools/bulk-restore INVENTORY_UPDATE
POST /api/v1/spoolman/inventory/spools/bulk-* FILAMENTS_UPDATE
- Built-in update runs the same prepare_internal_spool_payload(...) +
weight_used / weight_locked auto-stamp as the per-spool PATCH.
- Spoolman update loops the per-spool update_spool route function so the
filament re-linking / extra-dict / extra-lock / shared-filament rules
stay byte-identical to single-spool edits.
- Per-spool failures inside the batch are collected. Spoolman bulk-delete /
archive / restore now catch non-HTTPException too (matches bulk-update) -
a mid-batch httpx.ConnectError or TimeoutError no longer aborts the route
with a 500 and skips the WS broadcast.
- Both modes broadcast a single inventory_changed WS event at the end of
the batch.
New setting "Auto-add unknown RFID spools" under Settings -> Filament -> Filament Tracking,
default ON for back-compat. When turned off, the backend stops auto-creating an inventory
record for an unknown RFID tag and instead broadcasts an unknown_tag WS event that pops
a global confirmation modal in the Bambuddy UI showing the printer / AMS-X label / slot /
material / colour. Add or Cancel; no nag on every MQTT push.
Backend
- Module-level _unknown_tag_last_broadcast dict dedupes per (printer, slot, tag). Set is
committed AFTER ws_manager.broadcast() returns so a crashed broadcast doesn't poison
the dedup and permanently silence the slot.
- Empty-slot MQTT push clears that slot's entry, so remove+reinsert reliably re-prompts.
- Successful matches via get_spool_by_tag / find_matching_untagged_spool / create_spool
also clear the entry so a future tag swap re-prompts.
- Tray data (tray_type, tray_color, tray_sub_brands, tray_count) shipped in the WS payload
directly so the modal renders the real material / colour instead of relying on the
React Query cache that lags the WS event by several seconds.
- Two new endpoints back the modal's confirm action:
POST /api/v1/inventory/spools/from-slot (INVENTORY_UPDATE)
POST /api/v1/spoolman/spools/from-slot (FILAMENTS_UPDATE)
Both look up the slot's tray data server-side and create + auto-assign atomically.
- Spoolman /from-slot now raises HTTP 500 when the slot-assignment INSERT fails instead
of returning success while the DB rolled back the binding.
- sync_ams_tray gained an optional auto_add_unknown_rfid kwarg (default True so existing
callers are unaffected); auto-sync and both manual sync routes thread the setting.
Frontend
- useUnknownTagPrompt hook listens for the unknown-tag CustomEvent, reads the tray fields
out of the event detail, and feeds a single-modal queue. No long-lived dismissed set;
the backend dedup handles spam suppression.
- UnknownSpoolModal wraps the existing ConfirmModal with a material + colour-swatch
preview block.
- Mounted in Layout.tsx alongside useSponsorPrompt so SpoolBuddy kiosk / login / setup
routes are excluded.
- getAmsLabel moved to utils/amsHelpers.ts; ConfigureAmsSlotModal.tsx and PrintersPage.tsx
both import the shared version (canonical AMS-A / HT-A / External labels).
- AppSettings TS interface gained spoolman_enabled, auto_add_unknown_rfid, spoolman_url
so the runtime cast in the hook is no longer needed.
- SpoolmanSettings.tsx gets a new toggle row in the Filament Tracking card, visible in
both built-in and Spoolman branches; auto-save + toast already wired.
Closes the cross-repo contract started in bambuddy-appliance: the firstboot
wizard writes /etc/bambuddy/local.toml with the user's hostname / timezone /
locale, but nothing on the main app side read it. Hostname + timezone are
already applied by the appliance's firstboot.sh via hostnamectl /
timedatectl. This PR closes the loop for the third field — locale — so the
language the user picked in the wizard actually shows up on first SPA load.
backend/app/core/local_config.py
New module. read_local_toml(path) returns a LocalConfig TypedDict
({hostname?, timezone?, locale?}) parsed from /etc/bambuddy/local.toml.
Defensive on every failure mode -- missing file returns {}, invalid TOML
returns {} + log warning, non-string values dropped with warning. The
reader never raises; a malformed config never blocks startup.
backend/app/api/routes/system.py
New endpoint GET /system/appliance. Returns {hostname, timezone, locale}
with null for any field not present in the TOML. No auth required: the
frontend i18n bootstrap reads this before auth might be set up, and the
contents are user-set defaults, not secrets. The function calls
read_local_toml() with no args (default path) so tests can monkeypatch
the module's read_local_toml reference to inject fixtures.
frontend/src/i18n/index.ts
One-shot applyApplianceLocale() runs after i18n.init(). Gated by a
bambuddy_appliance_locale_consumed localStorage flag so it runs at most
once per appliance. Fetches /api/v1/system/appliance, validates the
returned locale against supportedLngs, calls i18n.changeLanguage if
valid. Silent .catch() because the endpoint absent / unreachable means
non-appliance install or dev environment -- we leave the LanguageDetector's
choice in place. The consumed flag is set on success; future loads skip
the fetch entirely. Won't override a user's explicit language pick (the
language picker writes to a separate localStorage key, bambutrack_language).
Third and final piece of #1268, alongside the recursive-search +
README-panel commit that landed earlier in 0.2.5b1. Folders express
hierarchy (one home per file); tags are orthogonal labels — "toy",
"kid-safe", "petg-only" — and a single file can carry as many as the
user wants. Reporter wanted to find "every toy regardless of which
folder it lives in"; folders alone can't do that without forcing the
file into one bucket.
Design decisions locked with maziggy before code:
- file-only (folders already express hierarchy)
- multi-tag filter = AND
- tag filter IGNORES the selected folder (cross-cutting by design)
- bulk-tagging from multi-select toolbar in v1
- no auto-tags from 3MF metadata (user-authored only)
- label-only chips, no color/icon
Backend
- LibraryTag (id, name, name_key UNIQUE = LOWER(TRIM(name)))
in backend/app/models/library.py. Case-insensitive UNIQUE
collapses "Toys"/"toys"/"TOYS " into one row, so the route
returns 409 instead of silently fragmenting the catalog.
- LibraryFileTag(file_id, tag_id) association, composite PK,
ON DELETE CASCADE both directions. Deleting a tag drops every
chip; files survive. Deleting a file drops its tag links; the
catalog row survives.
- Both tables auto-create via Base.metadata.create_all — no
explicit run_migrations step needed for new tables.
- New router at backend/app/api/routes/library_tags.py with:
GET /library/tags (list + per-tag file_count)
POST /library/tags (create, 409 on case-insensitive dup)
PATCH /library/tags/{id} (rename, 409 on collision, self-rename OK)
DELETE /library/tags/{id} (cascade)
POST /library/tags/bulk-assign (add | remove | replace)
- Bulk-assign add is idempotent; replace with empty tag_ids clears
the file's tag set. Per-file ownership enforced — *_OWN callers
can only modify their own files; unknown file_ids quietly
skipped (matches library_trash bulk shape).
- list_files gains tag_ids: list[int] query param. AND semantics
via JOIN + GROUP BY + HAVING COUNT(DISTINCT) — portable across
SQLite and Postgres. When tag_ids is non-empty, folder_id /
project_id / include_root / recursive are all bypassed so the
result is cross-cutting.
- FileListResponse gains tags: list[{id, name}] via
selectinload(LibraryFile.tags) — N+1-free chip render.
- Permissions reuse existing constants: LIBRARY_UPDATE_ALL for
catalog mutations (global catalog, ownership-aware update isn't
meaningful), LIBRARY_UPDATE_ALL/OWN pair for bulk-assign,
LIBRARY_READ_ALL/OWN for list — file_count projection narrows
for *_OWN callers so chip counts match what they actually see.
Frontend
- LibraryTagsModal (catalog CRUD) opens from the toolbar's new
Tags button. max-w-4xl so multi-language subtitles don't wrap.
Delete-with-warning when file_count > 0 ("removes the chip from
all of them; files themselves are untouched").
- BulkTagsPickerModal opens from the multi-select toolbar (new
Tag button between Move and Delete). Add/Remove radio,
checkbox list, inline "create new tag" disabled on dup.
Apply disabled until at least one tag is selected. The replace
action is exposed in the API but deliberately NOT in this UI —
arbitrary multi-file replace is destructive and confusing.
- FileManagerPage integration:
* selectedTagIds state, sorted into the useQuery key so the
cache hits are stable regardless of toggle order
* filter rail above the file list lists EVERY catalog tag as
a togglable chip — inactive outlined, active filled green
with an X. Clear all when 1+ active. Bar hidden entirely
when catalog is empty.
* useEffect prunes selectedTagIds when a tag is deleted from
the catalog so the filter never strands on a phantom id
* dedicated Tags column in list view at minmax(0,200px)
between Prints and Actions
* grid view chips render below the metadata block
* chip clicks stop propagation so they don't toggle file
selection
- libraryTagsQueryKey extracted to frontend/src/utils/
libraryTagsQuery.ts so component files export only components
(Vite react-refresh rule).
- LibraryFileListItem.tags is OPTIONAL even though the backend
always emits an empty array — legacy msw mocks in pre-existing
tests construct partial file shapes without the field. Without
the ? the FileCard renderer crashed on .length and broke 49
unrelated tests across FileManagerPage + FileManagerExternalFolder.
Read sites use file.tags ?? [].
Reporter (@zumik3-del, seconded by @unLieb) asked for three File Manager
improvements: recursive search, tags, and a markdown preview side panel.
This commit ships the two scoped ones; tags is held back gated on the
"give the issue a thumbs up" interest check Martin posted on the issue
because it's a much larger surface (M2M schema, CRUD endpoints, tag UI +
filter + autocomplete + i18n for the management surface) and isn't the
right call without a real demand signal.
1) Recursive search inside the selected folder.
Until now, selecting "Toys" and typing "robot" only found files
directly in Toys/ — anything under Toys/Cars/Race/ stayed invisible.
The page's client-side filter ran over a server-narrowed listing
(/library/files?folder_id=X is strict equality on folder_id), so the
client filter couldn't see what the listing never loaded.
list_files (backend/app/api/routes/library.py:1729+) gains a
recursive=true query param. When combined with folder_id, the route
walks library_folders.parent_id via a recursive CTE rooted at the
requested folder and returns every descendant folder's files in one
query. Recursive CTEs work on both SQLite >=3.8.3 (2014, well below
Bambuddy's runtime floor) and Postgres without dialect branching.
Default off so the existing folder-browsing call sites (Project /
Archive detail, the FE's no-search case) keep their narrow scope.
FE opts in only when both a folder is selected AND searchQuery is
non-empty (FileManagerPage.tsx — derived as searchExpandsSubfolders,
threaded through the useQuery key so the cache invalidates on
toggle). Small "Including subfolders" caption renders under the
search input when active so the user understands why a file from two
levels deep showed up.
2) Per-folder markdown description panel.
New endpoint GET /library/folders/{folder_id}/readme returns the
first .md file in the folder as {filename, content, truncated}.
Selection prefers README.md / readme.md / description.md
(case-insensitive via func.lower(filename) LIKE '%.md' + an
in-Python stem-preference sort), falls back to the
alphabetically-first *.md otherwise. 404 when no markdown is present
so the FE can hide the side panel — non-users pay no UI cost.
Bytes are clipped at 512 KiB (_README_BYTES_CAP) with a truncated
flag so the panel can warn the reader. UTF-8 decode uses
errors="replace" so one bad byte never blanks the panel.
New FolderReadmePanel.tsx fetches on folder-select and renders via
react-markdown@9 + remark-gfm@4 (tables, strikethrough, task lists).
Collapsible (default expanded), max-height 24rem with internal
scroll. react-markdown 9 doesn't render raw HTML by default — no
dompurify needed. Links open in a new tab with rel=noopener
noreferrer. Tailwind has no typography plugin in this project so
per-element components map h1/h2/h3/p/ul/ol/code/blockquote/table
to explicit utility classes that match the rest of the app.
Scope and permissions.
Both endpoints reuse the existing LIBRARY_READ_ALL / LIBRARY_READ_OWN
ownership-aware pair, so a viewer-tier user with read_own only sees
their own files in recursive listings and can only fetch the README of
folders containing their own files. No new permission, no DB migration.
The recursive CTE is a single SQL query — no N+1, no per-folder
round-trip, scales to deeply-nested model libraries.
When the printer reports ams_filament_backup=True,
compute_deficit_for_queue_item pools remaining_grams across spools
matching (preset, colour) on the same printer (scoped per extruder on
dual-nozzle) before declaring a per-slot shortfall. Identity is strict:
same slicer_filament preset AND same colour (alpha-normalised). Two
PETG HF spools in different colours are NOT pooled — the firmware would
swap correctly but the print would change colour mid-run. Spoolman side
mirrors the rule via filament.id + color_hex. Backup OFF falls back to
the pre-PR per-slot accounting line-for-line.
8 new test cases in TestFilamentDeficitBackupAware pin pool covers,
pool insufficient, different presets, backup-OFF regression, dual-
extruder side scoping, no-preset never pairs, colour-strict, and
alpha-hex normalisation. The 8 pre-existing test_filament_deficit.py
cases stay green.
feat(printers): AMS Filament Backup modal with BS-style ring per pair
Badge click on the Filaments section header (#1766) now opens a
modal: filament-colour ring per backup pair, material name + rotation
count in the centre, slot labels distributed around the colour band on
contrast-aware pills. Closely modelled on Bambu Studio's Auto Refill
widget. Lone slots are intentionally not listed. R / L badges per ring
when the extruder map carries two distinct values; collapses to no-
badge rendering for single-nozzle printers misflagged as dual.
Esc keypress closes the modal. Theme-aware via CSS variables matching
AMSHistoryModal. computeBackupGroups helper in utils/amsHelpers
defensively dedupes duplicate ams.id entries observed on switch-VP
aggregations.
10 modal render cases pin: Esc closes / unmount nulls the listener /
ring renders for pairs and omits lone slots / R-L badges only when
extruder map has distinct values / empty state / toggle gating.
13 frontend cases pin computeBackupGroups identity rules.
feat(printers): active-print P-N pill on AMS slot tiles during RUNNING
While the printer is mid-print, each AMS slot tile referenced by
status.ams_mapping carries a small "P1 / P2 / P3" pill in the top-
right corner, naming which print-slot is mapped to that AMS slot.
Catches the #1762 comment-2 scenario: a queue job set for "any X1C"
staged to a printer with mismatched filament, no way to verify mid-
print. Same wire data (status.ams_mapping is already on the wire) —
the addition is purely surface.
The existing ring-bambu-green highlight for effectiveTrayNow keeps its
meaning (currently extruding RIGHT NOW); the pill is the per-slot
static assignment for the active print.
chore(scheduler): log Print Anyway short-circuit at INFO
_block_on_filament_deficit logs at INFO when it honours
item.skip_filament_check, so a future "Print Anyway didn't work" report
(third commenter on #1762 hit this shape) has actionable evidence in
the standard support bundle without DEBUG. Bundled because the deficit
fix makes the original symptom disappear for users with backup ON.
Split Obico failure-detection dispatch out of the multiplexed
on_printer_error event onto its own on_ai_failure_detection event so
users can subscribe to AI alerts without also enabling HMS hardware-
error pages, and so the discoverable label "AI Failure Detection" is
what subscribes them rather than the unrelated "Printer Error" toggle.
New column on notification_providers (default False, branched
SQLite/Postgres migration), new notification_service.on_ai_failure_detection
method, new ai_failure_detection template, obico_actions._notify swap.
Frontend gets a summary badge, a toggle row with description, and ntfy
priority surfacing. 14 new tests pin the routing + the regression guard
("Printer Error" alone must NOT receive AI notifications now). 11 locales
covered.
Existing providers keep working: HMS hardware errors continue to ride
on_printer_error unchanged; users who want spaghetti alerts opt in via
the new toggle.
Adds page-wide drag-and-drop file upload to the File Manager
tab — drop any file anywhere on the page and the upload modal
opens pre-populated with the dropped files. The Upload Files
button still works for click-to-browse.
Also fixes the Archives drag-cancel bug @maikolscripts reported
in the same issue: cancelling a drag (drag back outside the
browser, Escape mid-drag, or release outside the page) used to
leave the overlay stuck until page refresh.
Both pages share a new hook usePageFileDrop. The fix:
- relatedTarget containment check (catches drag-out-of-window)
- document-level drop / dragend / keydown(Escape) listeners
that only register while isDraggingOver === true, so the
three cancel paths all reset uniformly
FileUploadModal gains an optional initialFiles prop so the
File Manager page can pre-seed the modal from a page-wide
drop. seededInitialRef guards against re-adding on re-renders.
Permission gate: File Manager drop zone disabled when the
user lacks library:upload, so a viewer-tier user doesn't get
a misleading overlay.
Adds an "ETA" option to the Printers page sort dropdown.
Sorts the fleet by remaining print time so the printer that's
finishing next sits at the top — useful for staging the next
job's filament ahead of time.
Tier ordering:
- Tier 0: currently printing with remaining_time > 0
(sorted ascending by remaining minutes)
- Tier 1: currently printing without an ETA yet
(post-start_print window before total time is known)
- Tier 2: idle / finished
- Tier 3: offline
Name tiebreaker within every tier. The asc / desc arrow
still applies after tiers resolve.
Data source is the cached remaining_time (minutes) on the
per-printer status query — the same field the per-card ETA
label and the fleet "next finish" badge already read from.
No new backend round-trip; the sort consumes data that's
already in React Query cache and updated on every WebSocket
push.
groupedPrinters returns null for ETA too — every printer's
ETA is unique so section headers would just produce a header
per row. Flat list, like the existing name sort.
Reporter has a lot of nested cad / slicer directories and wanted
"folders that just got a new 3MF" surfaced without scrolling the
alphabet. Tree was always alphabetical; LibraryFolder.updated_at
only bumps on rename / move, not on file-add inside the folder.
Backend exposes latest_activity_at = max(folder.updated_at,
max(immediate-child file.updated_at)) on FolderResponse +
FolderTreeItem. The /folders tree route picks up a sibling
func.max(updated_at) group-by alongside the existing file-count
subquery; the by-project / by-archive / single-folder routes
collapse count + max into one trip. Recursion across subfolders
is intentionally not computed - bubbles immediate parent only,
keeps the query a single GROUP BY rather than a recursive CTE.
Frontend adds a folder-sidebar sort dropdown (By name / By recent
activity) plus an asc / desc arrow, persisted in localStorage.
sortedFolders memo applies the comparator recursively so order is
consistent at every depth. Empty folders fall back to name within
the activity bucket so they never elbow a recently-used folder to
a random position. Both the desktop sidebar and the mobile selector
consume the sorted list so order is identical across breakpoints.
External folders: LibraryFile rows are created for scanned external
files too, so the aggregate works on them - but the timestamp
reflects last scan, not filesystem mtime. Documented in the wiki.
Same change also fixes File Manager list-view column alignment:
header and body were sibling grids with min-content as the trailing
column, computed independently. Header empty trailing div resolved
to 0; body action strip to ~220px. Different trailing widths gave
the 1fr Name column different remaining space, shifting every fixed
column to its right. Replaced min-content with fixed 220px in both
auth-on / auth-off grid templates.
Batches were stuck where they were created. The parent row had no drag
handle and wasn't registered with dnd-kit's SortableContext, so the
only way to move a grouped item was to ungroup, drag, and re-group.
Collapsed batches also acted as unmovable obstacles for adjacent items.
The batch parent now registers under a synthetic "batch-<id>" string
id and carries a GripVertical handle in the header (gated by
queue:reorder). handleDragEnd resolves both endpoints: dragging a
batch moves all its children as one block, dropping onto a batch
anchors at the batch's first child so the group lands immediately
before it. Direction-aware insert uses the first moving id's index
instead of the previously single dragged id, so multi-row drags and
batch drags share the same insert math. Within-batch child reorder
is unchanged. DragOverlay gained a batch ghost showing
"<name> (<N> copies)".
Reporter @thenewguy runs an engineering farm with one AMS per material
(PLA, ASA, Nylon, PVB, HIPS) — Bambuddy's single global ams_humidity_fair
threshold (default 60%) was driving both the queue / ambient auto-drying
trigger AND the hourly humidity alarm uniformly, which is wrong for
multi-material setups where Nylon wants <10% and PLA is fine at 60%.
Drying RUN parameters were already per-filament via drying_presets;
this commit adds the missing per-filament TRIGGER.
New setting ams_humidity_thresholds — JSON map of filament-type to
threshold percent with a "default" key for unknown / unmapped types.
Empty / unset → both consumers fall back to ams_humidity_fair so the
upgrade is silent.
Resolver lives in PrintScheduler.resolve_humidity_threshold(trays,
thresholds, fallback) — picks the lowest (most-restrictive) threshold
across all loaded tray types, matching the conservative-params strategy
_get_conservative_drying_params already uses for temp / hours. Empty
tray slots contribute no constraint; all-empty AMS falls through to the
"default" key. Filament names normalized to uppercase base (so
"PLA Basic" / "pla basic" both map to PLA).
Two consumer sites rewired through the same resolver so the scheduler
and the alarm path can never disagree about whether an AMS is "too
humid":
- print_scheduler.py::_check_auto_drying — per-AMS humidity comparison
for start / stop / skip decisions.
- main.py AMS sensor / alarm worker — hourly humidity alarm notifier.
UI: new table in Settings → Workflow → Auto-Drying, below the existing
Drying Presets table. Default row + 8 default filament types
(PLA / PETG / TPU / ABS / ASA / PA / PC / PVA) pre-filled from the
current ams_humidity_fair value so the editor starts sensibly.
Input pattern: draft-on-edit / commit-on-blur (transient humidityDrafts
state per row). onChange only updates the draft; onBlur (and Enter)
parses + clamps to [5, 95] + commits. Empty value on blur clears the
override and falls back to default. Caught mid-PR via a typing test:
the naive per-keystroke clamp snapped "3" → 5 before the user could
type the second digit of "30".
Setting is in the public _UI_PREFERENCE_FIELDS allowlist (same rationale
as drying_presets and ams_humidity_fair — non-sensitive integer map,
no SETTINGS_READ permission required for badge-color rendering).
Operator-flipped out-of-service state per printer for three scenarios:
parallel Bambuddy installs (dev + prod where the printer rejects all
but one MQTT client), printers under repair, and temporary suspension.
The backend Printer.is_active gate has shipped since day one and is
already honoured by every consumer — MQTT (printer_manager), queue
dispatch (print_scheduler, print_queue), metrics, scheduler, picker,
backup, maintenance dashboard. The missing piece was UI exposure.
Three entry points to flip is_active:
- Three-dot overflow menu (Enter / Exit maintenance mode, wrench icon)
- Exit button inside the in-card amber panel
- Checkbox in EditPrinterModal
Card UI: expanded mode shows an amber panel (Wrench + "In Maintenance"
+ subtitle + Exit) where the cover/progress container would normally
render — same height, no layout shift. Compact mode shows an amber
pill in place of the progress bar. Header pill swaps Connected/Offline
for "Maintenance" and the diagnostic CTA is suppressed (deliberate
state, not involuntary offline). HMS / Queue / Firmware pills fall
away naturally via the existing status?.connected gates.
Mid-print entry (RUNNING / PAUSE) triggers a confirmation dialog —
disconnecting MQTT mid-print stops progress tracking and completion
notifications for the in-flight job. Idle / FINISH / FAILED skip the
dialog and toggle directly.
Scope: no backend change, no new permission, no behaviour change for
any other consumer. PrinterCreate.is_active?: boolean added to the
TypeScript surface so the field flows through api.updatePrinter.
The Printers page rendered three fan widgets (part / aux / chamber)
for every printer unconditionally. Open-frame models (A1, A1 Mini,
A2L, P1P) have no chamber fan — the firmware reports big_fan2_speed
as 0, so the badge always rendered greyed-out and let users "set" a
fan speed on hardware that doesn't exist.
Adds MODELS_WITH_CHAMBER_FAN allowlist (X1C / X1 / X1E / X2D / P1S /
P2S / H2D / H2D Pro / H2C / H2S) near mapModelCode, and the chamber
entry is spread into fanItems only when the printer's model is in the
set. Open-frame printers now show two badges (part + aux), which
matches their actual hardware.
Allowlist not denylist: mirrors the file's existing classification
pattern (the enclosure-door badge gate uses the same shape), and the
failure mode is preferable — a missing badge on a real chambered
printer is obvious; a phantom badge on a future open-frame model
would look correct and silently lie.
In-app "Install Update" on Windows installer installs failed with "Could
not find git executable" because (1) _find_executable's fallback paths
are Unix-only, and (2) the installer stages backend/ via shutil.copytree
so there is no .git directory — even with Git for Windows installed, the
fetch would die on "not a git repository". Adding Windows paths would
only have changed which error users saw.
Switches the Windows installer path to a fourth update_method
("windows_installer") that mirrors the existing docker / ha_addon
branches — surface a link to the release .exe and let the user re-run
the installer, matching the Discord / Spotify Windows update model.
Backend:
- New _is_windows_installer_install() — true iff sys.platform == "win32"
AND no .git in app_dir, so Windows devs with a real git clone keep
the git path.
- New _find_windows_installer_asset() picks the matching release asset
(prefers versioned bambuddy-<ver>-windows-x64-setup.exe, falls back
to the unversioned alias on non-daily tags).
- /updates/check now returns is_windows_installer / update_method /
installer_download_url.
- /updates/apply short-circuits with a friendly message after the
existing HA / Docker guards — defense in depth, the frontend swaps
the button so the POST should not fire on Windows.
Frontend:
- UpdateCheckResult extended with the new fields and 'windows_installer'
in the update_method union.
- SettingsPage renders a Bambu-green styled <a target="_blank"
rel="noopener"> between the Docker snippet and the in-app Update
button, with installer_download_url falling back to release_url then
the tag page so the link is never broken.
- applyUpdateMutation onSuccess toast guard extended to treat
is_windows_installer the same as HA / Docker.
Reported off-list by a corporate supporter running a multi-operator
farm shift. Backend already rejects double-sends with HTTP 409 (see
background_dispatch._dispatch at lines 283-290), so no double-print
was possible, but PrinterSelector consulted only PrinterStatus.state
({IDLE, FINISH, FAILED}) to decide whether a printer was selectable.
PRINT_START is the only signal that flips the printer out of IDLE, so
during the upload + print-command + firmware-ack window the card
stayed clickable and operators only found out after submitting.
New useDispatchedPrinterIds hook subscribes to the existing
background-dispatch WebSocket event (already consumed for the toast
overlay), collects printer_ids from dispatched_jobs + active_jobs,
and exposes them via useSyncExternalStore so every PrinterSelector
instance shares one snapshot. OR'd into isPrinterBusy; badge label
flips to "Dispatching..." instead of a misleading "Idle".
No backend change — the reservation already exists, the UI just
didn't reflect it. No behaviour change for add-to-queue / edit-queue
modes (disableBusy stays false for those).
ghcr.io pull baseline (~10k/day rising → ~8-12k active installs) puts
sponsor conversion at 0.08% — roughly an order of magnitude under
industry-benchmark for OSS with visible CTA. The Settings banner from
0d4b9d4e gives passive every-visit visibility on one page; this adds
opt-out-able active visibility at moments where the user has just
earned something with Bambuddy.
Five trigger families with a 14-day cross-family cooldown: prints
(100/500/1000/2500/5000), cost (100/500/1000 tracked filament +
energy), archives (50/250/1000), anniversary (1 year), version-update
(re-armable on each major bump). New sponsor_toast_state table with
nullable user_id so auth-disabled installs get the same trigger logic
through one code path (NULL-keyed install-default row).
Matomo shows only 1.18% of website visitors reach /sponsors despite
29% hitting /installation. The ask was discoverable on the marketing
site but invisible in-app where users actually live.
Full-width gradient banner sits above the three-column layout on the
default landing tab and links to bambuddy.cool/sponsors.html with a
?from=app-settings tracking param so conversion lift is measurable
in Matomo. Three new sponsors.* keys translated to all 11 locales.
New PrinterSensorHistory table + 60s recorder + GET/DELETE /printer-sensor-history
route gated behind a new PRINTER_SENSOR_HISTORY_READ scope (separate from AMS). UI
adds a 10x10 LineChart icon on each heater tile - click body opens the existing
target-temp popover unchanged, click icon opens a HeaterHistoryModal mirroring the
AMSHistoryModal shape (kind toggle + 6h/24h/48h/7d range + current/avg/min/max +
recharts line for value + dashed target). Read-only X1C/P2S chamber tile finally
gets an interaction. Retention configurable via printer_sensor_history_retention_days
(default 30, sibling of ams_history_retention_days). 8 new i18n keys translated in
all 11 locales, parity green. 4 backend + 6 frontend tests added; full pytest -n 30
6226/6226, vitest 2176/2176, ruff/eslint/build all clean.
Two tightly-coupled deliverables in one drop -- a new AMS Filament Backup
status/control surface, and the #1766 fix that depends on it.
Added -- AMS Filament Backup status + control
- Parse bit 18 of top-level print.cfg into PrinterState.ams_filament_backup
on every push_status. Verified against OrcaSlicer source
(DeviceManager.cpp:4961) and a live H2D ON/OFF capture. Tri-state
(None = A1 family / pre-cfg push) preserves today's behaviour.
- Hold-timer guard (3 s) prevents stale frames from flickering the badge
back to the printer's old cfg after a user-initiated toggle.
- POST /printers/{id}/ams-backup toggle, set_ams_filament_backup() client
method calling _set_print_option("auto_switch_filament", enabled).
- GET /printers/{id}/inventory-remain endpoint exposes the same map the
dispatcher uses (internal and Spoolman modes both work uniformly).
- Small icon badge in the printer card's "Filaments" section header
(placement reads as printer-wide because the cfg bit is printer-wide,
not per-AMS). Click to toggle, success toast.
- 5 i18n keys x 11 locales for the badge UI.
Fixed -- #1766: prefer_lowest didn't pick lowest, ignored backup state
- Backend gate in _compute_ams_mapping_for_printer: coerce prefer_lowest
to False when status.ams_filament_backup is False; log the skip.
- New effectivePreferLowest(setting, backup) helper applied at every
frontend sort entry point: single-printer PrintModal, multi-printer
hook per-printer, PrinterSelector InlineMappingEditor, FilamentMapping
standalone editor (the last had NO preferLowest awareness at all
before this change).
- New preferLowestSortKey(f, inventoryByTrayId) mirrors backend's two-tier
key exactly, including the banding tie-break (regular AMS < AMS-HT <
external) so the client-side pre-compute matches the dispatch-time pick.
An earlier draft used a flat `amsId * 4 + trayId` priority which gave
external slots (ams_id = -1) a NEGATIVE priority -- caught in code
review before commit.
- Settings -> Filament -> "Prefer lowest remaining filament" gets an
explanatory note about the printer-side AMS Backup dependency, with
i18n key in all 11 locales.
The AMS humidity/temperature stats modal hardcoded color literals
gated on a light/dark boolean, so it ignored the active background
variant (neutral / warm / cool / oled / slate / forest) and the
light-bg variants. Switched modal chrome, stat cards, and the
recharts grid / axes / tooltip to read --bg-secondary, --bg-primary,
--border-color, --text-primary, --text-secondary, --text-muted from
the active theme so the modal follows mode AND background variant.
- The Speed and AMS load/unload tests broke after the printer-card
refactor in #1661 (icon-only Gauge button replaced the "100%" badge,
hover-card actions replaced the kebab "Slot options" button). Add
data-testid="speed-control" + data-testid="filament-slot" as stable
test hooks, rewrite both files around them. Parametrize the four-mode
speed-selection test. Update the "RUNNING hides menu" assertion to
"Load/Unload buttons exist but are disabled" — the new UX shows
actions on hover and disables them rather than hiding the trigger.
- Drop `animate-in fade-in-0 zoom-in-95 duration-150` from
FilamentHoverCard and EmptySlotHoverCard. The card briefly painted
at the offscreen (-9999, -9999) coords during the zoom-in transition,
reading as a fly-in from the upper-left corner. With the animation
gone it just appears in place at its computed position.
PR #1661 swapped the visible speed badge ("100%") for an icon-only Gauge
button and replaced the kebab "Slot options" button with hover-card
actions inside FilamentHoverCard. The two existing test files weren't
updated alongside the refactor and stayed broken on dev.
- Add data-testid="speed-control" to the Gauge button and rewrite the
Speed tests around it; assertions on the percentage text are gone
because that text no longer renders. Parametrize the four-mode API
call test.
- Add data-testid="filament-slot" to FilamentHoverCard's trigger
wrapper and rewrite the AMS load/unload tests around
fireEvent.mouseEnter → portaled actions. Replace the "hides menu
while RUNNING" assertion with "Load/Unload buttons exist but are
disabled" — matches the new UX shape.
GET /api/v1/printers/ and /api/v1/printers/{id} return access_code
only when the caller holds PRINTERS_UPDATE. Adds PrinterResponseWithSecret
as the elevated response shape; PrinterResponse no longer carries the
field. Auth-disabled single-trust mode preserved.
The Slicer -> Local Profiles page kept showing a just-deleted row for
the ~hundreds of ms it took invalidateQueries to refetch. A quick
re-click on the same row opened a second delete-confirm modal that
resolved to a 404 from the backend.
Add an optimistic queryClient.setQueryData filter in deleteMutation's
onSuccess so the row disappears the instant the DELETE returns 200.
Existing invalidateQueries calls stay in place to reconcile any drift.
Found while reproducing #1713 (verifying maziggy's setup against the
reporter's). Unrelated to that investigation but caught here.
Major version bump for the frontend build:
- vite ^7.3.2 -> ^8.0.16
- @vitejs/plugin-react ^5.1.1 -> ^5.2.0
Vite 8 swaps Rollup for Rolldown as the default bundler
(Rust-backed, same plugin contract). The bump also lifts the
transitive esbuild floor to 0.28.1, closing the last open
advisory in the audit chain.
vite.config.ts surface audited and unchanged:
- defineConfig, Connect type
- serveGcodeViewer configureServer middleware
- server.proxy with WebSocket upgrade for /api/v1/ws
- build.outDir / emptyOutDir / chunkSizeWarningLimit
- resolve.alias for @
- base: '/' regression guard from #1221
vitest@4.1.8 already accepts vite 8 in its peer range
(^6 || ^7 || ^8); no test-runner bump required.
Node floor for vite 8 is ^20.19.0 || >=22.12.0; CI Node 20.x
line satisfies this.
Not taken: plugin-react v6 — it requires
babel-plugin-react-compiler and @rolldown/plugin-babel as
peers and is a separate scope.
The 24h session cap from the M-2 audit finding was hard-coded, so the
"Remember Me" checkbox could only control storage location, never
duration. Add session_max_hours setting (default 24, max 720) honoured
at all four token-issuance sites: plain login, 2FA TOTP/email, 2FA
backup, OIDC.
- backend/app/core/auth.py: SESSION_MAX_HOURS_HARD_CEILING + resolver
that clamps to [1h, 720h] and falls back to 24h on missing/blank/
unparseable. DB errors propagate — the login transaction must abort
on a broken DB rather than silently extend or shrink the lifetime.
- backend/app/api/routes/auth.py, mfa.py: all four sites read the
resolved value instead of ACCESS_TOKEN_EXPIRE_MINUTES directly.
- backend/app/schemas/settings.py, routes/settings.py: schema field
with ge=1 le=720 + int coercion in _build_settings_response.
- frontend/src/pages/SettingsPage.tsx: half-width card at top of
Settings -> Users left column with 24h/7d/30d presets, custom input,
and a yellow warning when value > 24h.
- frontend/src/i18n/locales/*.ts: 8 new keys per locale, real
translations in all 11 (en/de/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW).
- backend/tests/integration/test_session_policy.py: 15 tests across
resolver clamping, login JWT exp end-to-end, settings API round-trip.
Already-issued tokens keep their original expiry; the new setting only
affects future logins.
The SpoolBuddy inventory page reimplemented its filter inline and only
matched material/subtype/brand/color_name/note, while Bambuddy's main
inventory uses the shared filterSpoolsByQuery helper which also matches
spool ID, slicer_filament_name, and storage_location. Delegate to the
shared helper so both pages stay in lockstep.
- frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx: replace
inline filter with filterSpoolsByQuery
- frontend/src/__tests__/pages/SpoolBuddyInventorySearch.test.ts: lock
in ID / partial ID / pre-fix fields / parity-gain fields
Restructures the queue page around three tabs (Queue / History / Timeline)
and adds first-class batch grouping plus a real time-based timeline.
Queue tab
- Layout toggle: Sort by Position (flat list) or Group by Printer (per-
printer section cards with aggregate count / time / weight headers).
- Batch grouping: pending items sharing a batch_id render as a single
collapsible row with aggregate stats; children draggable within the
batch only. Per-batch collapse state in localStorage.
- Multi-drag: dragging any selected row moves all selected items as a
contiguous block via DragOverlay (+N ghost).
- Selection bar gains a Group as batch action when 2+ ungrouped items
are selected. Ungroup lives on the batch parent row.
History tab
- Two-line rich rows: filament color swatch + weight + type, user
attribution, inline error message on failed / skipped rows.
- Responsive 1 / 2 / 3 column grid so a long history uses available
width instead of stretching one row per line.
- Batch siblings group into a collapsible parent with status-rollup
chips (3 OK / 1 failed / etc).
- Thumbnail hover preview shows the full image at 192x192 next to the
small thumb.
Timeline tab
- Replaces the hourly-list view with a Gantt swimlane: one row per
printer (plus per target_model and unassigned), horizontal hour
axis, jobs as bars positioned by start time and sized by duration.
- Live NOW marker.
- Only committed schedules are rendered: currently printing items,
pending items with scheduled_time, and pending ASAP behind an active
print. Staged (manual_start), waiting (waiting_reason), and ASAP
jobs on idle printers are filtered out.
- 24h rolling window with 12h step controls.
- Per-bar tooltip with start, end, progress, batch name.
Backend
- POST /queue/batches creates a batch, optionally assigning existing
pending item_ids (manual grouping) or returning an empty batch the
client can attach to subsequent /queue/ POSTs.
- POST /queue/batches/{id}/ungroup clears batch_id from all members
(skipping items the caller does not own) and deletes the batch row
when no members remain.
- POST /queue/ accepts an optional batch_id and validates that the
batch exists, is active, and the caller may modify it. The existing
quantity > 1 auto-batch path still fires when no batch_id is sent.
PrintModal
- When N plates from one source are queued in a single submission
(model assignment or single printer), the modal pre-creates a batch
and passes its id to each addToQueue call so multi-plate jobs land
grouped automatically. Falls back to ungrouped items if the batch
pre-create fails.
navPermissions in Layout.tsx gated three resources on the LEGACY *:read flag.
Default Operators group is seeded with *_own only (and the migration map flips
legacy → _own on existing groups), so non-admin users never held the legacy
permission and the sidebar hid Archives / Queue / Files even though the
underlying API accepted their requests. Reporter only spotted Files; same bug
shape applied to Archives and Queue.
Fix: navPermissions accepts Permission | Permission[]; the three affected
resources list all three tiers. isHidden checks .some(hasPermission) for
arrays. Permission type extended with the matching *_own / *_all variants —
backend already shipped them, the TS type just didn't declare them.
ProtectedRoute and PermissionRoute now pass the requested location as
router state when redirecting to /login. LoginPage stashes it in
sessionStorage before the OIDC provider redirect (since window.location
kills React state) and consumes it on all three post-login navigations
(credentials, 2FA, OIDC token exchange). Targets are sanitized to
same-origin internal paths only — protocol-relative and /login itself
are rejected to prevent open-redirect.
QR labels (https://host/inventory?spool=N) now land on the scanned
spool instead of the printer page after authentik / any OIDC SSO login.
VP queue-mode multi-plate Send All
==========================================
BambuStudio / OrcaSlicer "Send All" of a multi-plate project uploads ONE
3MF containing every plate (one FTP STOR, single filename) — slice_info.config
inside the file lists N <plate> blocks with their own index metadata and
their own Metadata/plate_N.gcode payload. Pre-#1733 the VP queue path
called _extract_plate_id which returned only the FIRST plate index, and
_add_to_print_queue built exactly one PrintQueueItem from it. Plates 2..N
silently dropped on the floor. From the user's perspective: Send All of a
3-plate project produced 1 queue item, indistinguishable from a regular
single-plate Send, with no log line to explain the discrepancy.
The wire was confirmed against the live H2D-1 Proxy VP: the same file
ships whether the user clicked Send or Send All; the only intent signal
is the count of <plate> blocks inside slice_info.config.
Fix: replaced _extract_plate_id (-> int | None) with _extract_plate_ids
(-> list[int]). The list contains every <plate> block's index in order;
falls back to [1] when slice_info.config is missing / unparseable so the
single-plate case is preserved. _add_to_print_queue now loops over the
list and creates one PrintQueueItem per plate, with:
- plate-specific position = MAX(position) + iteration_number, so the
items inherit consecutive positions and the slicer's plate order
becomes the queue execution order.
- per-plate required_filament_types / filament_overrides via
extract_filament_requirements(file_path, plate_id) — the plate-aware
filter shipped with #1697 — 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.
- shared archive_id across all plates (one upload = one archive row).
- the VP's auto_dispatch + manual_start posture inherited unchanged.
Net behaviour: single-plate Send hits the loop once → exactly today's
result (one queue item, plate_id from the slicer, one archive). Multi-
plate Send All of a 3-plate file → 3 queue items, plate_id 1/2/3,
consecutive positions, all referencing the same backing archive.
Archive delete cascades to queue rows
=============================================
Previously the soft-delete path (the default the trash-can button uses)
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.
Backend:
- Replaced _cancel_pending_queue_items with _delete_related_queue_items
(db, archive_id) -> int. DELETEs every queue row where
archive_id = X regardless of status. Matches what the hard-delete
path already did via the ON DELETE CASCADE FK on
print_queue.archive_id — both paths now produce the same end state.
- Print history lives in PrintLogEntry (FK ON DELETE SET NULL) and is
untouched; Quick Stats / accuracy bands are preserved across both
delete paths.
- 409 guard on archives.py::delete_archive when any related queue
item is currently status='printing'. Both soft and hard delete are
gated; 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.
- New GET /archives/{id}/delete-impact endpoint returns
{related_queue_items: N, currently_printing: M}. Cheap, single
endpoint, deliberately 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 the new endpoint when the
modal opens (useQuery with enabled: showDeleteConfirm) and renders
an amber "N queue items linked to this archive will also be removed"
line when total > 0 AND printing = 0, OR a red "Cannot delete —
M queue items are currently printing" line when printing > 0
(confirm button disabled in that case so the user can't bonk the
409 on submit).
- ConfirmModal gained an optional confirmDisabled?: boolean prop —
isLoading was the only disable knob before; this adds the external-
precondition path.
- 2 new i18n keys (deleteQueueItemsWarning, deleteBlockedByPrinting)
translated across all 11 locales per feedback_translate_dont_fallback —
no English fallbacks.
No DB migration — the CASCADE FK was already in place; only the helper's
semantics changed.
The per-archive Print Log table cell at ArchivesPage.tsx:3882 rendered
filament_color as a single swatch with
`backgroundColor: entry.filament_color.startsWith('#') ? ... : undefined`.
For multi-color prints, the backend writes filament_color as a comma-joined
string ("#FFFFFF,#000000,#FF0000"). The whole string trivially passed the
startsWith('#') check but isn't a valid CSS color — the browser silently
drops the declaration and the swatch falls back to its black/20% border,
which on the dark theme reads as a barely-visible grey dot. Reporter's
screenshots showed "PLA" text with no visible swatch at all. DB column
was correct; render dropped the colors.
The Archive Card view at ArchivesPage.tsx:1072-1083 and :2114-2125 already
splits on comma and renders one swatch per color — only the Print Log
table cell had been missed when multi-color support landed elsewhere.
Fix mirrors the card pattern: wrap swatches in a flex container, split
on comma, trim, render one w-3 h-3 swatch per color with
backgroundColor and title={trimmed}. Single-color prints render one
swatch (no behaviour change). Empty / non-hex entries fall through to
no backgroundColor rather than poisoning the CSS for siblings.
Does NOT cover the reporter's second symptom — new multi-color prints
missing from filament usage history. That's usage_tracker._track_from_3mf
and the slot-to-tray mapping chain; needs a support bundle (PRINT START
+ PRINT COMPLETE [UsageTracker] log lines + captured ams_mapping) before
shape can be confirmed. Tracking as #1731 part 2.
The Filament Profile picker in the Configure AMS Slot modal listed
profiles for every printer the user had ever imported / cloud-synced.
On H2D the reporter saw local "Custom" PETG/PLA for A1 mini and P1S
alongside Bambu Cloud and Orca Cloud profiles named "X1C eSUN PETG-
Basic Filament" - none of them usable on the slot they were configuring.
Three filter gaps in the same picker:
- Local "Custom" imported profiles were unconditionally listed. Now
parse their compatible_printers JSON and run presetCompatibility()
against the slot's full slicer preset name ("Bambu Lab H2D 0.4
nozzle") derived from the printer-model registry + slot nozzle.
Hide on 'mismatch'; 'match' and 'unknown' keep showing (back-compat
for hand-edited imports without compatible_printers).
- Cloud presets with the "@Bambu Lab <long-name>" suffix form (user-
renamed Bambu Cloud presets, most Orca Cloud profiles) slipped
through the existing "@BBL <code>" matcher. extractPresetModel now
handles both, with case-insensitive registry reverse-lookup so
"A1 mini" vs "A1 Mini" capitalisation drift doesn't hide A1 Mini
profiles (#1649 alias match preserved).
- Cloud presets with the model in the BODY of the name and no @ suffix
("X1C eSUN PETG-Basic Filament") returned null from the extractor.
Added a body-text scan against every known model token from the
registry, long-first sort so "A1 Mini" / "X1 Carbon" / "H2D Pro"
aren't eaten by their shorter siblings, word-boundary regex so
"PA1" doesn't false-match "A1".
Fail-open posture preserved: registry not loaded or printerModel empty
no-ops every filter; saved preset bypass keeps the active selection
visible; built-in filaments stay unfiltered (generic fallback); free-
form names with no recognisable token still show.
Before this change, the Re-print and Schedule modals' per-filament
slot dropdown hid every slot whose extruder didn't match the filament's
slicer-assigned nozzle. On H2D with AMS A+C on the left and B on the
right, an L-assigned filament could only pick A or C and an R-assigned
one could only pick B — locking the user out of cross-extruder picks
even when they'd intentionally loaded the required filament into the
other AMS.
The slicer wasn't the source of the asymmetry: BambuStudio Desktop,
OrcaSlicer Desktop, and the Bambuddy sidecar all produced identical
filament_map values for the same source 3MF. Bambuddy's UI filter was.
Drop the f.extruderId === item.nozzle_id clause in FilamentMapping's
loadedFilaments filter. Single-nozzle and FTS short-circuits stay; the
L/R row badge stays as a hint to the slicer's intent. Printer firmware
decides at start-print whether the cross-extruder ams_mapping is valid.
slice_and_persist writes a .gcode.3mf ZIP container but persisted the row
with file_type="gcode". The G-code preview endpoint short-circuits on
file_type == "gcode" and returns the bytes as text/plain, so the embedded
viewer received the raw ZIP body instead of the embedded toolpath.
- Persist file_type="gcode.3mf" on sliced rows (matches _classify_file_type
and external-scan rows).
- get_gcode also routes to the unzip branch when the filename ends with
.gcode.3mf, so rows already written under the bug self-heal on first
preview without a DB migration.
- Extend FileManagerPage badge + viewer-eye gate and ProjectDetailPage badge
to accept "gcode.3mf"; isSlicedFilename / isSliceableFilename already do.
- Add test_library_get_gcode_recovers_legacy_gcode_type_for_3mf: legacy
row preview must be text/plain, contain G28, and NOT start with PK.
Bundle import never delivered what it implied: BambuStudio's .bbscfg export
strips system processes/filaments, so importing a bundle left users without
process presets and slicing fell back to embedded settings on STL. Bundle
mode also hid the standard tier behind a constrained dropdown, the actual
trap reported here.
Removed end-to-end:
- backend: POST/GET/DELETE /slicer/bundles*, SliceRequest.bundle,
SliceBundleSpec, dispatch fork in library.py, bundle-context params on
the filament-requirements endpoints, bundle-fingerprint cache key in
slice_preview.py, SlicerApiService.{import,list,get,delete}_bundle and
slice_with_bundle, BundleSummary / BundleNotFoundError.
- frontend: BundlePicker + BundleStringDropdown, isBundleMode + every
branch, bundle state/queries/dispatch in SliceModal.tsx, SlicerBundle /
SliceBundleSpec types, three bundle API methods. buildCompatibilityIndex
loses its bundle path; presetCompatibility keeps compatible_printers
plus the @BBL fallback.
- SlicerBundlesPanel turns into a permanent static notice explaining the
removal, alternative import paths, and the new slice-time lookup order
(Imported > Orca Cloud > Bambu Cloud > Standard sidecar fallback).
- i18n: slicerBundlesRemoved.{title,description,alternatives,lookupOrder}
translated across all 11 locales; slice.bundle*, slicerBundles.* keys
removed.
Fixed (surfaced by removing bundle mode):
- _resolve_cloud and _resolve_orca_cloud now force type per slot and pin
from: "system" on the payload before json.dumps. Bambu Cloud ships
type as "printer"/"print" and routinely empty `from`; the BS CLI's
--load-settings parser rejects both with return -5 / "input preset
file invalid". Standard tier already did this; cloud paths now match.
Round 2 fixed the model-mode FilamentOverride: tray_info_idx →
sub-brand, plus a material-disambiguated colour name from a new
/inventory/colors/by-material endpoint. The printer-mode panel that
renders the same 3MF (FilamentMapping) was reading the same raw
fields — item.type for the required label, getColorName(item.color)
for the swatch tooltip — and was not touched, so picking "Specific
Printer" still showed "Required: PLA - Black" for a slice the
"Any H2D" branch already labelled "Bambu PLA Matte - Charcoal".
Extract the three-query resolution machinery from FilamentOverride
into a shared hook useFilamentLabels (returns positional
{resolvedName, colorLabel} per slot). Both panels call it; both
read the same labels. The hook also owns extractMaterialHint so the
"strip leading brand token" rule has one source of truth.
FilamentMapping required-side now reads {resolvedName} instead of
{item.type}; swatch tooltip reads `Required: {resolvedName} -
{colorLabel}` instead of `Required: {item.type} -
getColorName(item.color)`.
The Print Queue's schedule dialog hid the per-slot "Force color match"
checkbox when a specific printer was picked, even though the scheduler
in print_scheduler.py:535 honours force_color_match regardless of how
the queue item was created. Model-mode ("Any A1") rendered
FilamentOverride which carries the checkbox; printer-mode rendered
FilamentMapping which had no force-match UI at all. Pure UI gap.
Extend FilamentMapping to accept optional forceColorMatch +
onForceColorMatchChange props, mirroring FilamentOverride's signature,
and render the same <Palette>-iconed checkbox under each filament row
when a handler is wired. PrintModal/index.tsx passes the existing
forceColorMatch state through — same object both modes write into so
toggling between modes preserves the user's selection. No new i18n
keys (printModal.forceColorMatch already ships in all 11 locales).