The PAT-token swap didn't fix the gh-pages fetch — same opaque "exit
code 1" from actions/checkout@v4 on both attempts of the retry loop,
with no underlying git stderr surfaced. Likely the wildcard-refspec
+ shallow fetch pattern v4 uses combined with something at the runner
side, but the action's swallowed errors make it untriagable from logs.
Replacing the gh-pages checkout with `git clone --branch gh-pages
--depth 1` using the same PAT, embedded in the URL. Direct, explicit,
and if anything goes wrong the real git error reaches the log instead
of "exit code 1". The recorded remote keeps the PAT, so the later
`git push` reuses it — no separate auth setup needed.
Identity config (user.name / user.email) moved into the clone step so
it lives on the freshly cloned repo; dropped the now-duplicate config
calls from commit-and-push.
Source checkout still uses actions/checkout@v4 since it never had a
problem (the default ref is the workflow's own commit, no wildcard
refspec required).
The default GITHUB_TOKEN failed at `git fetch` for the gh-pages
checkout step with opaque "exit code 1" and no surfaced git stderr,
even with permissions: contents: write set at workflow level.
actions/checkout's retry loop didn't recover.
Switching the gh-pages checkout to secrets.GHRS_GITHUB_API_TOKEN —
the same PAT jgehrcke/github-repo-stats already writes the branch
with one step earlier — keeps the auth chain uniform and avoids the
mismatch. persist-credentials defaults to true, so the subsequent
commit-and-push step in the same gh-pages directory picks up the
PAT automatically; no separate change to the push step needed.
fetch-depth: 1 left explicit because checkout@v4 defaults to it but
the value's load-bearing for this workflow (we only need HEAD of
gh-pages, not history).
GHCR exposes total + 30-day daily-pull counts only in the package page
HTML (no REST or GraphQL endpoint). jgehrcke/github-repo-stats has no
notion of container metrics, so post-process the report after it runs.
New: .github/scripts/ghcr_inject.py
- Scrapes Total downloads (exact integer from title="N", not the K-rounded
display) and the 30-day sparkline (rect data-merge-count, data-date).
- Merges per-day rows into maziggy/bambuddy/ghcr-pulls.csv on gh-pages.
Fresh window overwrites overlapping dates, so GitHub's late revisions
to the last 30 days self-correct; days older than 30 stay frozen at
whatever was captured while still in-window.
- Patches latest-report/report.html: adds a TOC entry, a Container
pulls (ghcr.io) section at the top, and a Vega-Lite line+point chart
whose theme/config is cloned from the existing Total clones chart so
it inherits the report's look-and-feel.
- Bracketed by HTML-comment markers so re-runs replace rather than
stack (jgehrcke regenerates report.html every tick; we re-inject).
- Hard-fails if either scrape pattern stops matching — silent fallbacks
would let the chart freeze without notice.
Workflow: after run-ghrs, checkout source + gh-pages, run the injector,
commit only if the diff is non-empty. Uses the existing contents: write
permission; no new secrets.
**Bambuddy 0.2.4.8**
**⚠ Upgrade Notes — Read Before Updating**
0.2.4.8 is a fix-led patch release on the same 0.2.4 code base — no schema breaks beyond auto-migrated column additions (dialect-branched for SQLite and Postgres), no Docker entrypoint changes. The in-app Apply Update button in Settings → System → Updates works for Docker and for any native install already on 0.2.4.x.
Three behaviour-change callouts to know about before you upgrade:
- SSO autologin landed as a per-OIDC-provider opt-in (#1589, requested by @einstux). No existing install changes behaviour on upgrade — local login stays enabled by default and no provider is marked as autologin. To enable: turn on the new `is_autologin` flag on exactly one enabled OIDC provider in Settings → Auth, optionally disable local login at the same screen, and unauthenticated visitors will be redirected to your IdP on mount. A `BAMBUDDY_LOCAL_LOGIN=true` env-var bypasses both gates if your SSO provider is unreachable, and `/login?fallback=local` is a bookmarkable always-shows-the-form URL. Two refusal modes protect against lockout: disabling local login is rejected unless at least one OIDC provider is enabled AND the calling admin has a UserOIDCLink row.
- Sponsor-prompt thresholds lowered to fire for typical installs. The lowest print milestone drops from 100 → 10, archives from 50 → 5, and cost from 100 → 25 (EUR). Installs that already saw a sponsor toast within the last 14 days won't see more — the cross-family cooldown is unchanged. Installs that have never crossed the old 100-print bar become eligible the first time they pass 10 prints. The toast itself is the existing one, copy unchanged.
- Printer card UI refresh (#1661). Visual change on the main Printers page — re-arranged for structure and readability. The Filaments section header now carries the new AMS Filament Backup badge (blue circle-arrow when on, dim when off, "?" on A1 family where the cfg bit isn't yet parsed). External tray slots show `L` / `R` inside the colour circle instead of a separate `Ext-L` / `Ext-R` caption underneath, equalising the bottom row's height. No data-model change; existing customisation persists.
Make a backup before upgrading via Settings → Backup → Create Backup. Native install with update.sh snapshots the database automatically and rolls back on failure. Docker and fully-manual paths don't.
**Docker**
```
docker compose pull
docker compose up -d
```
docker-compose.yml doesn't need refreshing for 0.2.4.8.
**Native install — recommended path**
```
sudo BRANCH=main /opt/bambuddy/install/update.sh
```
Snapshots the database first and rolls back on failure.
**Native install — manual path**
```
sudo systemctl stop bambuddy
cd /opt/bambuddy
sudo -u bambuddy git fetch --prune --tags --force origin
sudo -u bambuddy git checkout main
sudo -u bambuddy git reset --hard origin/main
sudo /opt/bambuddy/venv/bin/pip install -r requirements.txt
cd frontend && sudo npm i
sudo systemctl start bambuddy
```
**Windows install**
Download `bambuddy-0.2.4.8-windows-x64-setup.exe` from this release page (or the unversioned `bambuddy-windows-x64-setup.exe` alias for an always-latest link). The In-app "Install Update" button on Windows installs now uses the same release-asset flow — no more "Could not find git executable" failures.
---
**Highlights**
0.2.4.8 is dominated by three threads: **AMS Filament Backup** becoming a first-class surface, **SSO autologin** for operators running their own OIDC, and a heavy contributor-credited fix sweep across notifications, the Virtual Printer, SpoolBuddy, and the queue.
The AMS Filament Backup thread closes the gap reporters @jpcast2001 and @Arn0uDz hit on dual-AMS X1C farms — when one slot ran low, the deficit check ignored a same-material backup peer and blocked the print. Bambuddy now reads and writes the per-printer backup state from the printer card (new badge in the Filaments section header), mirrors it into the colour-strict deficit check (#1762), and threads it into the dispatcher's "Prefer lowest" sort (#1766) so the spool that ought to run dry first really does. The mid-print spool-switch attribution bug @biduleman hit (#1771) — the firmware's end-of-print `total_layer_num=0` push was clobbering the cached total, collapsing every previous segment to 0g — is fixed in the same train.
SSO autologin (#1589) lands as a per-OIDC-provider opt-in with safety refusals on the disable-local-login toggle to prevent lockout, plus an env-var recovery path and a bookmarkable `/login?fallback=local`. See Upgrade Notes for the full surface.
Notifications got a sweep: false-positive "Print Stopped" on reprint after MQTT reconnect (#1807), finish-photo dropped on FINISH-state fallback (#1790), completion notification scoping the whole multi-plate project instead of the printed plate (#1785), and "Printer offline" push never firing on the disconnect edge (#1752) all fixed by separate contributors and reporters.
The Virtual Printer surface got another #1780 round driven by reporter @mkoreen — three commits bumping the slicer-MQTT race window to 5s with retroactive stamp, correcting a VP intake key mismatch that was silently dropping every slicer field, and forwarding the H2C rack-swap nozzle pick from slicer to dispatch.
Smaller-but-load-bearing: **per-printer Maintenance Mode** (#1476) so a printer can be excluded from the dispatcher without unplugging it, **per-filament humidity threshold for auto-drying** (#1605), **drag-reorder for grouped queue items**, and **heater history** (nozzle / bed / chamber) tracked with a per-tile chart-icon overlay opening the history modal.
---
**New Features**
- AMS Filament Backup status badge + toggle on the printer card; "Prefer lowest" actually picks the lowest spool (#1766, reported by @biduleman). Three states — ON (blue circle-arrow), OFF (dim), Unknown ("?" on A1 family where the cfg bit isn't parsed yet). Click toggles via the new `POST /printers/{id}/ams-backup` endpoint (gated on `printers:control`).
- Backup-aware filament deficit check, colour-strict (#1762, reported by @jpcast2001 + @Arn0uDz). Pre-print check sums grams across same-material backup peers instead of treating each slot in isolation.
- SSO autologin + disable local username/password login (#1589, requested by @einstux). Per-OIDC-provider `is_autologin` flag and `BAMBUDDY_LOCAL_LOGIN` env-var recovery path. See Upgrade Notes.
- Per-printer Maintenance Mode toggle (#1476, requested by @IndividualGhost1905 / Ferdi SEVER). Excludes a printer from queue dispatch without disconnecting it — useful for swap-out / cleaning / firmware-flash windows.
- Per-filament humidity threshold for auto-drying + alarms (#1605, requested by @thenewguy). Replaces the single global threshold with per-filament overrides; falls back to global when unset.
- Heater history (nozzle / bed / chamber) tracked + per-tile chart-icon overlay opens history modal. Mirrors the existing AMS sensor history surface; same retention + sampling cadence.
- Updated printer card UI for structure and readability (#1661). See Upgrade Notes for the visual change summary.
- Drag-reorder for grouped queue items; collapsed batches no longer block adjacent rows.
- Forecasting groups spools by colour + Forecast UI rework (#1814, by @Keybored02).
- "Auto-add unknown RFID spools" toggle + global confirmation modal (#1764). Reporters with multi-operator workshops saw duplicate inventory rows accumulating; toggle defaults to today's auto-add behaviour for compatibility, the modal asks before adding when unset.
- In-app sponsor-toast at earned milestones (Prints / Cost / Archives / Anniversary / Version-update). Cooldown 14 days across all families.
- Prominent sponsor banner on Settings → General. Single dismissable banner above the existing settings list on the default landing tab.
- Lower sponsor-prompt thresholds. See Upgrade Notes.
---
**Changes**
- Printer card AMS row: external tray height matches regular AMS slots. L/R label now lives inside the slot's colour circle in place of the index; bottom `Ext-L` / `Ext-R` caption removed.
---
**Fixed**
**Notifications**
- False-positive "Print Stopped" notification on reprint after MQTT reconnect (#1807, reported by @volodymyr-doba).
- Print-complete notification no longer drops the finish photo when the FINISH-state fallback fires (#1790, reported by @needo37). Producer→consumer sync.
- Completion notification scoped to printed plate on multi-plate 3MFs (#1785). Multi-plate single-plate prints used to report the whole project's duration + material; now matches the queue card.
- Push notification for "Printer offline" actually fires (#1752, reported by @saint-hh). `on_printer_offline` dispatch wired on the disconnect edge — the callback existed but the wire was missing.
**Auth / OIDC / permissions**
- API keys with Manage Library permission can rename / delete / move library files (#1832, reporter @MorganMLGman). `LIBRARY_UPDATE_ALL` / `LIBRARY_DELETE_ALL` now map to `can_manage_library`; `LIBRARY_PURGE` stays admin-only.
- Sidebar entries for Files / Archives / Queue no longer hidden from non-admin users with granular `*_read` access (#1755, reported by @knifesk).
- Auth preserves the original URL across login + OIDC round-trip (#1750). Bookmarks land where the user clicked, not the dashboard.
- Printer secrets restricted to update-authority callers. Tightens which API surfaces can return access codes and credentials.
**Virtual printer / dispatch**
- H2C nozzle pick from Bambu Studio preserved on dual-nozzle rack variant + VP slicer-field intake (#1780, reported by @mkoreen). Three commits landed: slicer-MQTT race window bumped to 5s with retroactive stamp, VP intake key mismatch corrected, rack-swap nozzle pick forwarded to dispatch.
- Mid-print AMS Backup spool-switch correctly splits weight instead of crediting all to the second spool (#1771, reported by @biduleman). Cascades `state.total_layers` → `last_layer_num` → equal-split fence; firmware's end-of-print `total_layer_num=0` push no longer clobbers the cached total.
**Print queue + scheduler**
- `require_previous_success` no longer permanently blocks a printer's queue after a failure (#1818, reported by @jmassardo). Resume-after-failure clears the gate.
**Inventory / AMS / SpoolBuddy**
- Assign-spool picker note now visible on mobile (#793 follow-up, reporter @EmcetPL). Note rendered as a muted line under the weight on both internal and Spoolman branches; `title=` tooltip preserved for desktop hover.
- SpoolBuddy "Assign to AMS" preserves the user's slicer preset instead of pushing Generic (#1815, reported by @Bgabor997). Resolver now preserves PFUS / PFCN `setting_id`.
- SpoolBuddy inventory search matches spool ID, slicer filament name, and storage location (#1738, reported by @shaddowlink). Was filtering on spool name only.
- H2S active-tray highlight no longer stuck on AMS slot 1 during external-spool prints (#1822, reported by @ojimpo). `tray_now` promoted to 254 on all-external prints.
- Unknown-tag modal no longer pops for slots with no RFID.
**Connection / install**
- Connection diagnostic no longer reports false camera-port warning on A1 / A1 Mini / P1 (#1799 closing #1798, by @lesbass / Stefano Maffeis).
- Docker installer escalates on EACCES instead of failing on `/opt/bambuddy` (#1774, reported by @jmoore-skild). Auto-sudo on the directory create when needed.
- In-app "Install Update" on Windows installer switched to release-asset update flow — no more "Could not find git executable" failures.
**UI / rendering**
- Chamber-fan badge hidden on open-frame Bambu printers that have no chamber fan.
- Archive thumbnails rendered server-side when the sidecar slice skips them (#1759, reported by @VID-PRO).
- Local Presets page: deleted row optimistically removed instead of staying visible until refetch returned (which had allowed a second delete click → 404).
- AMS history modal respects theme background variant in stats modal.
- Post-#1661 printer-card cleanup — test fixtures + hover-card fly-in removal.
**Docs / archives**
- Archives "Step 4" docs link no longer 404s (#1812, reported by @Spanholz). Corrected `bambuddy.cool/wiki/...` host to `wiki.bambuddy.cool/...`.
- Archives backfill NULL `created_at` + tolerate NULL in response (#1732). Old archive rows from upgrades that ran before the column existed now render correctly.
---
**Security**
- dompurify 3.4.10 → 3.4.11 (GHSA-cmwh-pvxp-8882, moderate). Frontend HTML sanitisation library; no Bambuddy code change.
- Backend dependency security floor bumps — cryptography, python-multipart, starlette. Floor raises so fresh installs and CI pick up the fixed runtime.
- Floor pins for pydantic-settings ≥2.14.2 + msgpack ≥1.2.1 to clear `pip-audit`.
- Vite 7 → 8 + plugin-react 5.2 (major bump) + frontend dependency bumps.
- 422 constant rename — pinpoints the dependency-related security response constant.
---
**Contributors**
External code contributors with merged PRs in this release: @EdwardChamberlain (#1661 — Update printer card UI for structure and readability), @Keybored02 (#1814 — Forecasting: group spools by colour + UI rework), @lesbass / Stefano Maffeis (#1799 closing #1798 — A1 / A1 Mini / P1 camera-port diagnostic). Thank you!
The reporters who drove the fixes in this release are credited inline next to each Fixed entry above.
---
**Sponsors**
Bambuddy is sustainable thanks to people who put their money where their use is. If this release saved you time or kept your farm running, the project runs on recurring contributions — there's no paid tier, no telemetry, no upsell, just sustainable maintenance.
- **GitHub Sponsors** (recurring, 5 tiers from $5/mo to $300/mo) — https://github.com/sponsors/maziggy
- **Ko-fi** (one-time or recurring) — https://ko-fi.com/maziggy
Everyone supporting Bambuddy is named at https://bambuddy.cool/backers.html (and in `BACKERS.md` in the repo). Special thanks to @northpole3dprinting (Corporate tier) and all the Patron / Supporter / Backer sponsors who made this release possible.
Three test fixtures pass a string-shaped /tmp path into PrintArchive
rows. The file is never created — the field is just a DB column the
ORM accepts as a string — but Bandit's B108 rule fires on any literal
/tmp/ path it sees in source. Suppress with the same `# nosec B108`
marker convention test_archives_api.py and test_queue_start_user_attribution.py
already use for the same shape.
The original #793 fix added the spool note as an HTML title= tooltip
on each picker button in AssignSpoolModal.tsx. title= only surfaces
on hover, which doesn't exist on touch devices — phone users tapping
a card just selected it, the note never appeared. Users who store
their tracking ID in the note field were blind on mobile (raised by
@EmcetPL on the closed issue).
Render the note as a small muted truncated line directly under the
weight on both the internal-inventory branch (line 436-ish) and the
Spoolman branch (line 510-ish): text-[10px] text-bambu-gray/70 mt-1
truncate, kept inside the truthy `&&` guard so empty notes don't add
a blank row. The existing title={spool.note} is preserved on the new
<p> so desktop hover and mobile long-press still surface the full
untruncated text for notes that overflow the truncate.
Mirrored across both inventory branches per the parity rule
(internal and Spoolman pickers stay shape-equal). No backend change,
no new state, no popover, no new touch target.
require_ownership_permission gates API keys on `all_perm` only — the
comment at auth.py:1659 says OWN and ALL "both map to the same scope
flag" for queue / archives / etc., so checking `all_perm` is the
correct gate. Library deliberately broke that: LIBRARY_UPDATE_OWN /
LIBRARY_DELETE_OWN mapped to can_manage_library, but the ALL variants
were in _APIKEY_DENIED_PERMISSIONS. Result — every API-key request to
DELETE /library/files/{id}, PUT /library/files/{id} (rename), or
POST /library/files/move hit "administrative operations" 403, even
for keys with can_manage_library=True. Only slice worked, because it
doesn't go through require_ownership_permission.
The "ALL stays admin-only because it crosses the user boundary"
intent was internally inconsistent. API keys have no per-row
ownership identity (user=None), so the route's
`file.created_by_id != user.id` ownership check would AttributeError
on a key acting under OWN anyway — the only working path is
can_modify_all=True, which `all_perm` denial blocked outright.
Fix folds LIBRARY_UPDATE_ALL and LIBRARY_DELETE_ALL into
_APIKEY_SCOPE_BY_PERMISSION under can_manage_library, matching the
can_queue precedent (QUEUE_UPDATE_OWN and QUEUE_UPDATE_ALL both
map to can_queue for the same per-key-identity reason). Both removed
from _APIKEY_DENIED_PERMISSIONS. LIBRARY_PURGE stays denied — it
bypasses the soft-delete window and is genuinely destructive.
The toast was calibrated for power users — lowest bars were 100 prints,
50 archives, 100 cost. Most installs never crossed any of them, especially
with the install base ~doubling since March. Matomo confirms: only 4
prints-100 and 3 archives-50 deeplink visits to /sponsors.html in a 7-day
window despite tens of thousands of weekly pulls.
Adds lower thresholds without changing priority order or cooldown:
PRINT_MILESTONES = (10, 25, ...)
ARCHIVE_MILESTONES = (5, 10, ...)
COST_MILESTONES = (25, 50, ...)
Existing toast copy uses {count}/{total} interpolation in all 11 locales,
so no i18n changes. Tests rebalanced so "below the floor" still tests
with the new floor; new test_fires_at_lowest_threshold pins prints-10.
On dual-nozzle printers (H2C/H2D), the External card stacked a
separate "Ext-L" / "Ext-R" caption below each tray to mark which
extruder it fed. That caption appeared on the External card only,
making the bottom row of the printer card's AMS panel visibly
taller than the row above it.
Fix: the L/R distinction now lives inside the slot's colour circle
in place of the numeric index, and the bottom caption is removed.
FilamentSlotCircle's slotNumber prop is widened to `number | string`
to carry the letter. Single-nozzle externals (one tray, no L/R
distinction) keep the numeric "1".
The Ext-L / Ext-R strings still drive the slot's "location" label
in the filament hover card, so detail context is preserved.
pip-audit flagged two advisories at the resolved versions in the venv.
Neither is reachable in shipped Bambuddy, but the pins are taken so
the audit stays clean and a future reachable advisory in either
package isn't masked by existing noise.
pydantic-settings 2.14.2 patches GHSA-4xgf-cpjx-pc3j —
NestedSecretsSettingsSource with secrets_nested_subdir=True followed
symlinks pointing outside the configured secrets_dir, reading
out-of-tree files into settings values and bypassing the documented
secrets_dir_max_size cap. Affected: >=2.12.0, <2.14.2. Bambuddy uses
pydantic-settings only for env-var-backed config; the secrets-dir
loader is not used (grep clean on NestedSecretsSettingsSource /
secrets_nested_subdir / secrets_dir under backend/).
msgpack 1.2.1 patches GHSA-6v7p-g79w-8964 — reusing an Unpacker
instance after it caught an error can crash with SEGV, which is a
DoS vector on untrusted input. msgpack is not a runtime dep of
Bambuddy; it enters the tree only as a transitive of CacheControl,
itself pulled by pip-audit (the very tool that surfaced the
advisory). Pin placed in requirements-dev.txt next to pip-audit so
it travels with the security-scan tooling rather than implying a
runtime use.
Adds a global local_login_enabled setting plus a per-provider
is_autologin flag on OIDCProvider so operators who run their own SSO
enabled, or if the calling admin has no UserOIDCLink — either would
lock everyone out. App-layer invariant: at most one provider can carry
is_autologin; setting it on one clears it on every other.
/auth/advanced-auth/status surfaces both new fields so the LoginPage
decides UI in one query. The env-var bypass flips the reported
local_login_enabled back to true so the SPA matches what the route
will accept.
fix(db): order filament_shopping_list color_name ALTER after CREATE
PR #1814 added ALTER TABLE filament_shopping_list ADD COLUMN color_name
before the CREATE TABLE IF NOT EXISTS for that table. On fresh installs
the ALTER hit "no such table" — not in _safe_execute's swallow list —
and aborted run_migrations, breaking every migration test that starts
from a fresh DB. Moved the ALTER to after the CREATE on both SQLite and
Postgres branches; the CREATE already declares color_name, so this is
purely the upgrade path and "duplicate column name" on re-runs is
swallowed.
Reprints triggered a bogus "Print Stopped" push notification while the print
kept running, surfaced by the reconciler synthesising a missed PRINT COMPLETE
on MQTT reconnect.
bambu_mqtt:3647 mints a fresh subtask_id per dispatch. On reprint, the
on_print_start expected-archive promotion only wrote subtask_id when the
stored value was empty (`not archive.subtask_id`) — so the archive kept the
FIRST run's id. On the next MQTT reconnect, reconcile_stale_active_prints
(#1542) compared the stale stored id against the printer's live id, found
a mismatch, and synthesised a status="aborted" PRINT COMPLETE — which fires
the "Print Stopped" notification.
Captured cleanly in the reporter's support bundle:
[RECONCILE] Printer 1: synthesising missed PRINT COMPLETE for archive 31
— subtask_id changed ('1844213296' → '2103771517')
immediately followed by gcode_state: RUNNING on the same wire.
Fix: update archive.subtask_id whenever the new effective id differs from
the stored one, not only when the stored one is empty. Inequality check
preserves the noop-on-stable-push behaviour the original guard provided.
Two places in main.py (expected-print and duplicate-printing-archive
branches). 3 new unit tests cover the reprint, first-run, and stable-push
paths. Reconciler itself unchanged — it was doing the right thing given
the data it had.
The 7cb905a follow-up mounted the global unknown-tag modal listener, which
turned an existing always-on broadcast for no-tag slots from a silent no-op
into a perpetual popup loop — every push for a slot with a generic
non-RFID spool (or zero-filled tag) re-prompted, and confirming each one
created a fresh ghost spool with an empty tag.
- main.py on_ams_change: drop the no-tag else-branch broadcast. No identity,
no prompt; the slot stays unassigned until a real tag is read.
- inventory.py + spoolman.py /spools/from-slot: 400 when the slot has no
usable tag_uid / tray_uuid so stale frontends can't recreate the ghost
spool by re-confirming a queued prompt.
- test_inventory_from_slot_no_tag: lock the guard in (zero-filled + empty
string).
SpoolBuddy "Assign to AMS" with a Bambu Cloud user preset (PFUS) left
Bambu Studio showing "Generic <Material>" instead of the user's
custom preset. Root cause: the defensive filter that catches
PFUS/PFCN leaks into tray_info_idx also cleared setting_id —
but PFUS/PFCN are VALID setting_id values, just not valid
tray_info_idx values. When the cloud detail lookup didn't return
a filament_id (cloud unauth on the on_ams_change replay path,
transient failure, or older custom presets), both fields got
cleared and the caller's generic-material fallback overwrote
setting_id with GFSG99 — slicer resolved to Generic PETG.
Fix: the filter still clears tray_info_idx for PFUS/PFCN/material-
name leaks, but preserves setting_id when it's a valid slicer
reference (PFUS / PFCN / GFS). Material-name leaks still clear
both. Post-fix MQTT carries tray_info_idx=GFG99 (firmware-acceptable
for HMS/drying/colour) AND setting_id=PFUS<hash> (slicer uses this
to load the actual user preset).
What stays the same: Bambuddy's own AMS card still displays
the generic material on cloud-unauth paths — same fundamental
limitation as today. Fixing that needs a deeper layered fallback
(LocalPreset name match, printer kprofile query, cloud-detail
cache) and is out of scope for this drop. Slicer-side fix is
the reporter's explicit ask.
H2S firmware reports tray_now=0 (the AMS's idle slot) throughout
external-spool prints instead of 254 like X1C/P1S/A1 do, so the
single-nozzle branch's 0-3 passthrough landed state.tray_now on slot
0 — UI highlighted AMS SLOT 1 instead of the external spool.
Usage credit was unaffected (#1276 covers that via ams_mapping).
The single-nozzle branch now checks _captured_ams_mapping (slicer-
captured per-filament mapping that the request-topic intercept
already tracks) before the existing P2S multi-AMS resolver. When
every entry is -1, the print uses ONLY the external spool, so
state.tray_now is promoted to 254.
Narrow on purpose: AMS-only [5] and mixed [5, -1] are NOT
overridden — we have no evidence H2S misreports mid-print swaps, and
trusting the firmware preserves correctness for users with multi-
filament setups. No-mapping prints (printer-screen start) fall
through unchanged.
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).
Round 2 (166e9f9e) fixed the stash-key mismatch, but @mkoreen's
2026-06-23 bundle showed BS's MQTT project_file arrived 85 ms past the
2.0 s wait timeout (FTP done 00:42:02.509, "No slicer options cached"
00:42:04.509, MQTT 00:42:04.594). Queue item was committed with
settings defaults; nozzle_mapping never made it onto the wire.
Three pieces:
1. _SLICER_OPTIONS_WAIT_TIMEOUT module constant, 2.0 -> 5.0 s. Covers
wireless / loaded-Pi jitter; one-time +3 s cost only for legacy
slicers that never send MQTT.
2. _RECENT_QUEUE_ITEM_TTL fallback: on_print_command retroactively
UPDATEs slicer-driven fields on a recently-committed queue item
when the event wait already gave up. Tracked via
_recent_queue_items dict (30 s TTL, evicted on every queue-add).
Gated on status='pending' so we never race the dispatcher.
Multi-plate covered via WHERE id IN (...).
3. Post-commit last-chance pop. Audit caught a race in (2): MQTT could
arrive during any await inside _add_to_print_queue (wait_for,
archive_print, db.flush, db.commit), and on_print_command would
stash data with no event consumer AND no _recent_queue_items entry
yet. 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 inline.
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.
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.
On the FINISH-state fallback path bambu_mqtt.py:3258 dispatches
on_finish_photo_moment and on_print_complete back-to-back, so the
moment-producer's RTSP grab and the print-complete consumer's cache
read race — consumer wins the empty pop, then its own RTSP fallback
times out against the producer's in-flight grab (Bambu printers allow
one RTSP client). 394 KB frame captured, notification went text-only.
Add a per-printer asyncio.Event in _stage22_finish_in_flight: producer
registers before first await, sets it in finally on every exit;
consumer awaits with a 20s timeout (15s producer grab + headroom)
before the cache pop. Closes the race AND the concurrent-RTSP timeout
in one change. Timelapse path skips the event, so its branch is
unchanged.
First-attempt fix (d196cfc5) was wrong about the cause. Real root,
traced via @mkoreen's BAMBUDDY_VP_DUMP_WIRE capture + 2026-06-21
support bundle:
mqtt_server.py:1296 was passing the slicer's bare subtask_name
(e.g. "Model_Name") into on_print_command, which stashed under
that key. _add_to_print_queue looked up under file_path.name
(the FTP filename WITH extension, "Model_Name.gcode.3mf"). The
two strings never matched. pop returned None, the 2s wait fired
against a key the stash side never signaled, every captured
slicer field silently fell back to settings defaults.
Affected EVERY Bambu Studio "Send" upload across EVERY model —
not just H2C nozzle_mapping. bed_leveling / flow_cali /
vibration_cali / layer_inspect / timelapse from the original
#1403 capture have been silently ignored since BambuStudio
started splitting subtask_name (bare) from file (with extension).
Unit tests passed because fixtures called on_print_command with
file_path.name directly, bypassing the broken caller.
Fix in manager.py::on_print_command: derive
stash_key = data.get("file") or filename and use it for both
_slicer_print_options and the event lookup. filename
(subtask_name) still flows unchanged to _schedule_finish_release
— push_status echoes it back as gcode_file / subtask_name and
the slicer matches against its own subtask_name there, so
re-routing that path was a separate regression I caught and
reverted mid-audit.
Also: nozzles_info field was a wrong guess in d196cfc5 —
BambuStudio never sends it (confirmed via wire capture). Drop
the capture, dispatch, schema, kwarg, and route paths. DB
column stays nullable so old rows still load; nothing reads
or writes it.
Diagnostic: DEBUG log when _add_to_print_queue finds no slicer
options after the 2s wait, including the looked-up key and the
actual cache keys present. Future stash/lookup mismatches will
be obvious from a log line instead of needing a wire capture.
Behaviour change worth flagging: users on Bambu Studio whose
slicer-side bed-leveling / flow-cali / vibration-cali /
layer-inspect / timelapse differ from Bambuddy's
default-workflow settings will see their slicer choices
honored now instead of silently overridden. Restores #1403's
original intent.
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.
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.
Reporter forcefully started a print needing ~260 g with 180 g on the
first spool and a backup spool in the AMS. Printer correctly consumed
spool 1, AMS Backup switched, spool 2 finished the print. Bambuddy
attributed all 260 g to spool 2 -- spool 1 untouched in inventory.
Two stacking bugs produced the exact "all to second spool" symptom for
prints without per-layer 3MF gcode data:
1. bambu_mqtt.py:2135 wrote state.total_layers = int(data["total_layer_num"])
unconditionally. P1S firmware pushes total_layer_num=0 at print end
(same reset pattern other models do for layer_num / progress). The
unconditional write clobbered the slicer's actual total to 0 before
the usage tracker read it.
2. usage_tracker.py:1129-1137 linear-fallback dumped EVERYTHING onto the
last segment when total_layers was 0:
if total_layers > 0:
segment_grams = total_weight * (seg_end_layer - seg_start_layer) / total_layers
else:
segment_grams = 0.0 # <- entire print weight ends up on last segment
Path 2 (AMS remain% delta) couldn't recover because (a) the emptied
spool reported remain=-1 and (b) Bug-A had already added the second
spool's key to handled_trays, suppressing the Path 2 lookup.
Fix:
- bambu_mqtt.py: only overwrite state.total_layers when the incoming
value is positive (mirror of the existing _last_valid_layer_num
pattern at line 2127). Explicit reset on new print start at
_handle_print_start so the previous print's total can't bleed in.
- usage_tracker.py: cascade the linear-fallback denominator -
state.total_layers, then last_layer_num (already threaded in for
the last_progress fallback), then equal-split as a bounded fence.
Equal-split is still wrong but never dumps the whole print on the
last segment, which was strictly worse.
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 3MF parser sums prediction + weight across every plate (#1593) so the
archive card can headline the whole project — correct for the card, wrong
for the completion notification of a single plate. The queue UI already
re-reads the 3MF per-plate at print_queue.py:272-285; mirror that for the
notification path so Discord / Pushover / email show the plate's actual
duration and grams instead of the project sum. Helper fails open on every
error path so a missing or corrupt 3MF can't block the notification.
BambuStudio's project_file MQTT command for O1C2 (the H2C dual-
nozzle-rack variant) carries nozzle_mapping (per-filament physical
nozzle position IDs) and nozzles_info (per-extruder rack metadata).
The VP intake was dropping both, so the H2C firmware fell back to
"last matching nozzle type" auto-pick and ignored the user's
slicer choice — every HF print landed on R2, every standard print
landed on R4.
Carry both fields through the VP intake → queue item → MQTT
dispatch path. New nullable TEXT columns on print_queue, non-
branched ALTER (matches ams_mapping / filament_overrides
precedent). Dual-nozzle gate at start_print() keeps the fields
off single-nozzle dispatches. Fail-open on malformed JSON —
firmware auto-picks, never worse than pre-fix.
Stamps both fields on every plate in the multi-plate Send All
loop (#1697 / #1188 precedent).
ams_mapping2 still handles H2D/X2D dual-extruder routing
unchanged; this fix is scoped to the O1C2 rack-swap mechanism.
install/docker-install.sh::create_install_dir ran `mkdir -p
"$INSTALL_PATH"` without sudo while DEFAULT_INSTALL_PATH was
/opt/bambuddy, root-owned on every Linux distro. set -e then
aborted the whole script before docker compose could pull the
image — anyone running the documented `curl ... | bash` flow as
a normal user hit this on first install.
Fix: try the unprivileged `mkdir -p ... 2>/dev/null` first so
--path ~/bambuddy, /srv/bambuddy and other writable targets don't
trigger a needless password prompt, then fall back to
`sudo mkdir -p` + `sudo chown -R "$USER:$USER"` only when the
first attempt failed. The chown is load-bearing: without it the
script would later try to write docker-compose.yml + .env into a
root-owned dir as the invoking user and cascade further EACCES
failures.
Not changing the default path: install/update.sh and
install/update_macos.sh both default INSTALL_DIR to /opt/bambuddy,
and install/README.md's update flow documents the same — flipping
the install default to ~/bambuddy without coordinating those
would silently break self-service updates for anyone following
the docs verbatim. The default stays /opt/bambuddy; only the
escalation gap closes.
set -e survives the redirected stderr because the `if !` form is
the documented escape hatch for an expected-failure check.
Smoke-tested writable-target, idempotent-rerun, and the
failing-mkdir-then-sudo-fallback branches.