In-app updater was failing on installs whose local clone had stale
tags (e.g. v0.2.1 re-pointed upstream after a post-release re-tag).
git fetch --prune --tags returns a non-zero exit when even one tag
would be clobbered, even though origin/main and the target release
tag itself fetched cleanly:
! [rejected] v0.2.1 -> v0.2.1 (would clobber existing tag)
...
ERROR Git fetch failed: From https://github.com/maziggy/bambuddy
The updater surfaced this as "Failed to fetch updates" and aborted,
leaving the user stuck on the previous release.
Adding --force lets the moved tag overwrite the local stale copy.
That matches the in-app updater's contract ("sync me to the remote")
and is what the native update.sh sidesteps entirely by not using
--tags at all. The in-app path can't drop --tags because release-tag
refs (v0.2.4b1, v0.2.4.1, etc.) need to be resolvable locally for the
subsequent git reset --hard.
Regression test asserts --force is in the fetch args alongside the
existing --tags assertion.
The CI-only failure on test_layer_timelapse_expected_archive came from
the test driving the entire on_print_start flow through ~12 patches and
a MagicMock printer, which behaved differently between Python 3.11 (CI)
and 3.13 (local) — execution stopped silently somewhere in the
expected-archive path under CI's pytest-xdist parallelism but completed
locally.
Fix root-shape instead of fix the symptom:
1. Extract the three identical start_session call sites in on_print_start
(expected-archive promotion at 2030, fallback archive at 2554, fresh
archive at 2644) into one helper _maybe_start_layer_timelapse() with
the same external_camera_enabled / external_camera_url guard. The
three inline blocks had already started drifting (#1353 originally
only fixed one of them on the first pass) — the helper keeps them
locked together going forward.
2. Rewrite the test to call the helper directly. Uses SimpleNamespace
(strict attribute access) instead of MagicMock (default-truthy), no
DB mocking, no event loop, no parallel-state surface. Four small
cases instead of two integration-style ones: enabled→starts,
disabled→skips, URL-missing→skips, camera_type default 'mjpeg'.
Test was passing locally but failing in CI on the merge of v0.2.4.1.
Root cause: MagicMock returns a truthy default for any unset attribute,
so mock_printer.plate_detection_enabled was truthy and the production
code's plate detection block ran for real. Locally with ffmpeg present
the capture path completed cleanly (try/except swallowed errors) and
flow continued to start_session. In CI without ffmpeg, the failure path
in capture_camera_frame_bytes prevented execution from reaching the
expected-archive branch's start_session call, so the assert_called_once
on the patched start_session tripped.
Plate detection isn't the subject under test (start_session in the
expected-archive branch is). Setting plate_detection_enabled = False
explicitly on the mock skips that block entirely and makes the test
robust to environmental differences. Test also drops from ~9s to ~2s
since real frame-capture attempts no longer run.
**Bambuddy v0.2.4.1**
⚠ **Upgrade Notes — Read Before Updating**
Almost everyone is upgrading from 0.2.4. 0.2.4.1 is a patch release: stability and correctness fixes built on the same code base as 0.2.4, no schema breaks, no Docker entrypoint changes, no Vite/proxy quirks. The in-app Apply Update button in Settings → System → Updates resolves to the latest stable tag and works for all users — no flags needed.
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 — none of the entrypoint, volume, or env-var conventions changed since 0.2.4.
**Native install — recommended path**
sudo BRANCH=main /opt/bambuddy/install/update.sh
**Native install — manual path**
sudo systemctl stop bambuddy
cd /opt/bambuddy
sudo -u bambuddy git fetch origin --tags
sudo -u bambuddy git checkout main
sudo /opt/bambuddy/venv/bin/pip install -r requirements.txt
sudo systemctl start bambuddy
**Behaviour changes to know about**
- Reprint stats are now per-event, not per-archive. Re-printing a file no longer overwrites the source archive's totals; Quick Stats and the per-archive Print Log gain an orange N prints badge with a per-run breakdown (successful + failed). If you already had a reprint that overwrote stats in 0.2.4, the existing archive row keeps its current numbers — but every new print event from 0.2.4.1 onward writes a separate PrintLogEntry, so totals start adding correctly again. (#1378)
- Pending queue items for soft-deleted archives are now auto-cancelled. Soft-deleting an archive (default delete path) removes its files from disk, which makes any pending queue item pointing at it un-dispatchable. From 0.2.4.1 those items get status=cancelled + waiting_reason="Source archive deleted" so you see why the queue item disappeared from pending instead of finding it silently stuck. (#1348 follow-up)
- i18n parity check now blocks English-leak in non-English locale files. The build's parity step (npm run build) now fails CI when a non-English locale entry equals the English source unless explicitly allow-listed as a cognate. 2,377 accumulated English fallbacks across 7 locales were translated in this cycle as the underlying cleanup. No user-visible change today — just no more "Advanced" buttons in your German UI from new keys going forward.
---
**Highlights**
0.2.4.1 closes correctness gaps that hit power-users running queues, reprints, and Obico fault detection at the same time. The three biggest are: per-event stats aggregation so reprints add to Quick Stats instead of overwriting (#1378), camera stream no longer freezes when Obico polls the same printer (#1348, reported by @SL666), and multi-color archive cost now charges untracked AMS slots at the default rate instead of reporting near-zero (#1344, reported by @nicktags). Around them: AMS slot configuration that survives Reset Slot on A1 Mini BMCU / P1S Standard AMS, MakerWorld URL import, queue/VP-dispatched prints finally getting layer timelapse, plate detection respecting the external camera setting, firmware checks staying alive when bambulab.com Cloudflare-blocks the page, LDAP manual provisioning, and a narrow API-key permission for the Home Assistant dynamic-tariff integration.
Plus a deep i18n debt cleanup — 2,377 strings translated across 7 locales — and mechanical CI enforcement so the debt can't accumulate again.
---
**New Features**
- Manual LDAP user provisioning from the UI (#1298) — Add LDAP users into Bambuddy without waiting for their first login. Pre-create groups, permissions, and inventory ownership before the user even authenticates once.
- Build-plate override in the SliceModal (#1337) — Pick which build plate (Cool / Cool SuperTack / Engineering / High Temp / Textured PEI / Smooth PEI) to slice for, independent of the source 3MF's embedded plate. The slice respects the override end-to-end (sliced output is bound to the chosen plate, archive metadata records it, printer card thumbnail matches).
- API Keys: narrowly-scoped "Update electricity price" toggle (#1356) — New per-key permission flag exposes a single endpoint POST /api/v1/settings/electricity-price that accepts {"energy_cost_per_kwh": <float>}. Closes the gap where the wiki documented a Home Assistant dynamic-tariff rest_command example that was never deliverable (every key with general SETTINGS_UPDATE is hard-denied for security). The new flag does NOT widen general settings-write access — the broader PATCH /settings route remains denied. Wiki updated; existing keys default off.
- Per-archive Print Log view + clickable "N prints" badge (#1378 follow-up) — Every archive card with more than one print event shows an orange N prints badge with a hover-tooltip breakdown (successful vs failed). Click it (or use the context-menu entry) to open a print log dialog showing every print event for that archive — date, status, duration, filament, cost — with failure_reason text under failed runs. Also embedded as a section at the top of the Edit Archive modal so the history is one click away.
---
**Improved**
- Reset Slot on A1 Mini BMCU / P1S Standard AMS no longer deadlocks Assign Spool (#1322, reported by @RosdasHH) — The empty-detection that gated ams_filament_setting was too cautious; now only short-circuits on state ∈ {9, 10} (firmware's explicit "no spool" codes) so the post-Reset-Slot "spool inserted, state=3, tray_type empty" case fires MQTT and configures the slot. Same Reset Slot click that previously sat in pending state forever now lands cleanly.
- Multi-color archive cost now tops up untracked AMS slots at the default rate (#1344, reported by @nicktags) — A 110 g multi-color print with only one of four trays mapped to inventory used to show $0.01 instead of ~$1.10. Untracked slots now charge at the global default filament cost. Fully-tracked prints are unchanged.
- Plate-detection calibration captures from the configured external camera (#1359, reported by @Andlar94) — On printers with an external RTSP / go2rtc camera enabled, calibration was previously sourcing the reference frame from the built-in chamber camera while the runtime check used the external one — guaranteeing a "Build plate not empty" false-positive on every print. Both paths now share the same external-camera default, with a backend-side derivation so future callers can't drift again.
- Layer timelapse now starts for queue / VP-dispatched prints (#1353, reported by @Andlar94) — The timelapse start_session() call was only on the new-archive code paths. Queue dispatches and VP-dispatched reprints landed on the expected-archive branch and silently lost timelapse. The expected-archive branch now mirrors the same gate.
- Firmware update dialog survives Cloudflare-blocked / transient outages on bambulab.com (#1350, reported by @K1ngJony) — Adds honest browser-like Accept / Accept-Language headers alongside the existing Bambuddy/1.0 UA, persists the resolved buildId to disk (so a single bambulab.com 403 doesn't permanently break download-URL resolution for that session), retries once on 404 (Bambu rebuilt the page), and shows an honest error message when the download endpoint truly can't be reached.
- Subtype dropdown on the Add/Edit Spool form offers CF and GF (#1345) — Adding a third-party PETG-CF / PLA-CF spool no longer requires typing the variant by hand.
- Page-header visual style unified across the app (PR #1272 by @EdwardChamberlain) — Every page now uses the same icon-aligned heading shape.
- OIDC provider icons proxied server-side (PR #1342 by @netscout2001) — Icon fetches no longer expose the issuer's URL via browser request logs / DNS.
- Auto-print start G-code now fires after the printer reaches RUNNING, not before (#1304) — The first RUNNING transition after Bambuddy boots no longer fires an unrelated print-start; users with custom G-code injection get their snippets at the actual start of each print, not the boot of the daemon.
- i18n parity gate now enforces real translations in every locale, not English fallbacks — frontend/scripts/check-i18n-parity.mjs gains a new Check 4 that fails CI when a non-English leaf equals its English source unless explicitly allow-listed as a cognate. The 2,377 accumulated English fallbacks across the 7 non-English locales were translated as the underlying cleanup. Going forward, "English fallbacks per project convention" is not a thing — new keys must be translated in every locale or explicitly added to the per-locale IDENTICAL_TO_EN_ALLOWED cognate list.
- Support bundle records more application state — Adds OIDC providers + 2FA / API key / long-lived token counts, library / inventory / queue / maintenance totals, slicer-API CLI versions, GitHub backup status, per-printer Obico flag. Redacts two settings that were previously included in cleartext and fixes a reachability-check architecture bug. Future triage rarely needs a follow-up "can you also send X" round-trip.
---
**Fixed**
**Stats / Archives / Print Log**
- Reprints (including failed and cancelled ones) no longer overwrite the source archive's statistics (#1378, reported by @IndividualGhost1905) — Statistics are now event-based, not file-based. The existing PrintLogEntry table gains six columns (archive_id, cost, energy_kwh, energy_cost, failure_reason, created_by_id); /archives/stats and /metrics sum from it. Each print completion writes a new row with the run's actual filament / time / cost / energy / status. The cost overwrite at
usage_tracker.py:633 and energy overwrite at main.py:3625 now both preserve the source archive's first-run values on reprints; the run's actuals are stored on the PrintLogEntry row instead.
- Partial prints record accurate run filament (#1378 follow-up) — Failed / cancelled / stopped reprints no longer record the source archive's slicer estimate verbatim. New _compute_run_filament_grams helper prefers sum of tracked spool deltas, then falls back to estimate × progress%, then None — captured in 14 unit tests across every combination of status × inventory-tracked.
- Print log no longer 404-storms thumbnails for entries whose archive was deleted or whose print failed before extraction (#1348 follow-up) — Two-part fix: route self-heals on first 404 (NULL the cached path on the entry so subsequent renders skip the request) + eager NULL at archive-delete time so future deletes don't fire the one-time storm.
- Soft-deleted archive no longer leaves linked queue items silently stuck in pending forever (#1348 follow-up) — Pending queue items pointing at soft-deleted archives are now cancelled at delete time with waiting_reason="Source archive deleted". Queue API also suppresses the cached archive thumbnail / name / metadata when deleted_at is set, and the queue page's /plates query is gated on a new archive_deleted flag — three 404s per orphaned queue row are now zero.
**Camera**
- Camera stream no longer freezes every ~30s when Obico fault detection is enabled on the same printer (#1348, reported by @SL666) — Obico's _capture_frame was reusing the fan-out broadcaster's buffered frame when available, but falling through to a competing RTSP socket in race windows where the buffer was momentarily empty (stream startup, mid-reconnect). On X1-class firmware that allows only one camera connection, that second socket kicked the live viewer. New is_stream_active() helper now gates the fresh-socket fallback independently of the buffer state — when a viewer is connected, Obico never opens a competing socket.
**AMS / Inventory**
- Bare-tray empty-slot signal on P1S / A1 Mini (#1322 follow-up) — Genuinely-empty AMS slots on these printers send {"id": N} only (no state, no tray_type). The AMS parser now promotes this shape to state=9 so the inventory route's state ∈ {9, 10} short-circuit fires and we don't waste an ams_filament_setting publish that firmware would silently drop.
- AMS slot configuration lands cleanly for spools with no k-profile — The "configure" call no longer 422s when the spool's filament has no calibration profile entry. Affects a long tail of third-party / generic-PLA spools.
- AMS slot configuration lands on firmwares that never report state=11 (#1322 follow-up) — Some older firmwares never report the literal state=11 for loaded; the configure path's gate was too strict. Now treats absence of explicit empty (state ∈ {9, 10}) as loaded.
- Spool removal from AMS on X1C firmware that reports power_on_flag=False while idle (#1365, reported by @an3k) — Empty-slot detection now narrows the skip to zero-bits + power_on_flag=False (the shutdown shape from #765) instead of any power_on_flag=False. Spool pulls between prints now register without a manual Reconnect.
- AssignSpoolModal sits above the mobile sidebar drawer (#1336) — z-index fix; clicking Assign on mobile no longer opens the modal behind the drawer.
- Catalog color's gradient + effect now applied, not just hex (#1340) — Picking a Bambu Lab gradient or sparkle entry from the colour picker now copies all three colour properties.
- Storage location persists for internal spools (#1291) — Local-mode inventory now writes the storage_location field on save (was Spoolman-only).
**Spoolman**
- AMS-HT range allowed in slot-assignment table (#1274) — The ams_id upper bound was hardcoded at 4; AMS-HT extends the range. Now matches the parser's range.
- External-spool ams_filament_setting uses global tray_id (#1279) — Was sending the local slot ID for external spools; firmware rejects.
- Persist color_name edits without round-tripping the subtype synth fallback (#1319) — Editing the colour name on a Spoolman spool no longer reverts after the next AMS push.
- Restore Spoolman spool ID search + Unassign button (#1336) — Two regressions from the Spoolman inventory UI work that landed in 0.2.4.
- Resolve -1 in ams_mapping to external spool (#1276) — Bambu's multi-color slicer convention; the queue dispatcher now interprets it correctly.
- Per-print 3MF tracking is the only weight writer (#1119) — Removes a competing path that double-wrote weights in Spoolman mode.
- External library lookup filtered by Bambu Lab manufacturer (PR #1330 by @ojimpo) — Stops cross-manufacturer matches polluting the Bambu library picker.
**Virtual Printer / Slicer**
- VP cache preserves AMS / vt_tray / net.info across incremental push_status updates (#1371, reported by @Andlar94) — Slicer no longer needs a printer power-cycle to see AMS info on a queue-mode VP. The bridge's _latest_print_state cache now preserves a small set of sticky keys (ams, vt_tray, ams_extruder_map, mapping, net, ipcam, lights_report) when an incremental push omits them — mirrors what Bambuddy already does for its own internal state.raw_data.
- VP emits FINISH after FTP upload so Print-flow slicers un-wedge (#1280) — BambuStudio's Print-flow path waits for FINISH before clearing its upload progress UI.
- VP broadcasts archive_created so Archives page refreshes live (#1282) — Slicing through the VP now updates the open Archives page without a manual refresh.
- VP queue-mode honours workflow default print options (#1235) — VP-dispatched prints now pick up the user's "Auto-Print start gcode" / "Auto-Off" / etc. defaults consistently.
- Slicer bundle import logs the sidecar's reject reason (#1312 follow-up) — Failed .bbscfg imports now show the upstream error in the Bambuddy log so users can diagnose without curling the sidecar.
**Scheduler / Dispatch**
- Watchdogs no longer falsely treat FINISH → IDLE as "print landed" (#1370, reported by @Martinnygaard) — Queue items dispatched onto a printer that was in FINISH (un-dismissed "Print complete" prompt from a prior job) used to stay stuck at printing forever. The post-dispatch verifier now narrows the "command landed" check to an allow-list of active-print states (PREPARE / SLICING / RUNNING / PAUSE).
- First RUNNING after Bambuddy boots no longer fires a phantom print-start (#1304) — Cold-boot of Bambuddy onto a printer that's mid-print no longer creates a stray archive at the boot moment.
**Camera**
- Plate-detection UI uses the external camera when configured (#1359) — Above under Improved.
- Layer timelapse for queue/VP-dispatched prints (#1353) — Above under Improved.
- Camera fan-out broadcaster buffered frame shared with Obico + /camera/snapshot (#1271) — Reuse path landed in 0.2.4; this cycle's #1348 fix completes the race-free version. Listed for completeness.
**Notifications / Backups**
- Discord webhook accepts legacy discordapp.com URLs (#1363, reported by @mrfoureyed) — Discord's Copy Webhook URL button still emits the legacy hostname; validation now accepts either.
- Backup tab indicator dot for scheduled backups (PR #1338 by @chanakyan-arivumani) — Visual cue when a backup is queued.
**Auth / LDAP / OIDC**
- Manually-assigned groups preserved across LDAP logins (#1292) — LDAP user re-login no longer wipes admin-assigned group memberships.
- Orphan OIDC / MFA rows cleaned up when user is deleted (PR #1295 by @netscout2001) — Deleting a user now cascades to their OIDC binding + TOTP secret rows.
- Password rules shown in user-create form + FE/BE checks aligned (#1303) — Frontend rejected passwords the backend would accept and vice versa; now both apply the same rules and the form shows them.
- External-scan STL thumbnails deferred + Path coerced (#1299) — External library scans no longer block on STL thumbnail rendering; mountpoints expressed as strings work alongside Path objects.
- MakerWorld settings link points to /profiles (#1300) — The "Open Cloud settings" link from the MakerWorld page now goes to the right tab.
**UI / Misc**
- Smart-plug live wattage rounded to whole watts on the printer card (#1266, reported by @Carter3DP) — Plugs reporting fractional watts (ESPHome / HA-bridged) no longer overflow the card.
- Settings UI rendering fields exposed without requiring SETTINGS_READ (#1293) — Non-admin users with narrower scopes can now load the Settings UI; the rendering-only fields (theme, locale) are no longer gated on admin-tier read.
- Bed-jog Z direction inverted on A1 / A1 Mini bed-slingers (#1334) — Up was down on bed-slinger printers; now matches the physical motion.
- Usage tracker: skip remain% fallback for trays not used by the print (#1269, reported by @maugsburger) — Swapping spools in unrelated AMS slots mid-print no longer charges the original spool the full estimate.
- Soft-deleted archives keep their Quick Stats contribution (#1343) — Was already there for archive-level totals; this release locks it in via the new PrintLogEntry event aggregation (#1378) which references log entries by ON DELETE SET NULL, so the contribution survives even a hard delete.
- scan_timelapse picked stale video at false offset (#1278) — Resolved.
---
**Security**
- urllib3 floor raised to 2.7.0 to clear CVE-2026-44431 and CVE-2026-44432. urllib3 is a transitive dependency (none of Bambuddy's top-level deps require >=2.7.0 yet), so the resolver was silently keeping the vulnerable 2.6.x line. requirements.txt now carries an explicit urllib3>=2.7.0 pin.
- Bandit suppression syntax corrected on two verify=False calls in support.py — the two local-sidecar reachability probes used # noqa: S501 (ruff syntax, ignored by bandit) instead of # nosec B501. The probes themselves are unchanged (no payload, no secrets — health-check only) but the local security scan now passes cleanly without false-positive high-severity findings.
---
**Contributors**
Big thanks to everyone who shipped code or filed reproducible bug reports this cycle:
Code: @netscout2001, @EdwardChamberlain, @chanakyan-arivumani, @ojimpo, @maziggy
Reproducible bug reports: @IndividualGhost1905, @SL666, @nicktags, @Andlar94, @RosdasHH, @an3k, @K1ngJony, @Martinnygaard, @Fuechslein, @mrfoureyed, @maugsburger, @Carter3DP
(See CHANGELOG.md for the full per-fix detail.)
Synthetic value on mock_archive.file_path was "/tmp/fake.3mf" - Bandit
flagged it as "Probable insecure usage of temp file/directory" even
though the string is never used as a filesystem path. Swapped to
"/test/archives/fake.3mf", matching the pattern used in commit 9f1188d7
to clear the same finding on test_library_trash_api.py and
test_pending_upload_display_name.py. No suppression comment needed -
the new path no longer matches Bandit's regex.
The two # noqa: S501 comments on the local-sidecar reachability probes
were using ruff/flake8 suppression syntax; bandit only honors # nosec,
so the scan flagged both calls as high-severity. Switched to
# nosec B501 with strengthened reasoning (reachability/health probe
only, no secrets in the request). No behavioural change.
urllib3 2.6.3 was being pulled in transitively (none of our top-level
deps require >=2.7.0 yet) and trips two recent CVEs. Direct pin in
requirements.txt forces the resolver to install 2.7.0, which is the
upstream-fixed release for both findings.
Opening the Print Queue page fired 404s on /archives/{id}/thumbnail,
/archives/{id}/plates, and /archives/{id}/plate-thumbnail/{n} for any
row pointing at a soft-deleted archive. Two underlying problems wearing
one mask:
1. Cosmetic: the queue API was copying item.archive.thumbnail_path into
archive_thumbnail without checking deleted_at. Soft-delete leaves the
row (so the relationship resolves) but removes the file from disk, so
the cached path was always stale.
2. Functional: a queue item whose 3MF was removed can never dispatch.
Without an explicit cancel, the item sits in 'pending' forever with
no indication to the user about why nothing is printing.
Fix in three parts:
- New _cancel_pending_queue_items() helper, called from soft_delete_archive
alongside the existing print-log thumbnail cleanup. Sets status='cancelled'
+ waiting_reason='Source archive deleted' on every pending queue item
linked to the archive. Only 'pending' is touched - completed/failed/
cancelled rows are historical and untouched. Hard-delete is already
covered by ON DELETE CASCADE on print_queue.archive_id.
- Queue API serializer now checks item.archive.deleted_at before
populating any archive-derived field. New archive_deleted: bool field
on PrintQueueItemResponse signals the soft-deleted state.
- Frontend's getArchivePlates query in QueuePage was gated on archive_id
only - archive_id is the real FK and stays exposed for dispatch/audit,
so added an explicit && !item.archive_deleted clause to respect the
new flag. Thumbnail render and CompactHistoryRow/QueueTimelineView
already gate on archive_thumbnail so the backend suppression alone
covers them.
Regression tests pin cancel-only-pending behavior, soft-deleted
suppression + archive_deleted=True flag, and the sanity guard that
live-archive fields keep flowing through unchanged.
Archives → Print Log was 404-storming the thumbnail endpoint on every
render: PrintLogEntry.thumbnail_path is copied by value from the archive
at write-time, but the FK on archive_id is ON DELETE SET NULL (#1378) so
log entries survive archive deletion to preserve stats history - and the
cached path keeps pointing at a file that was removed when the archive's
directory was deleted. Same shape for failed prints whose extractor never
wrote the thumbnail.
Two-part fix:
1. Route self-heals: get_print_log_thumbnail NULLs thumbnail_path on the
entry and commits before returning 404 when the file is missing on
disk. The frontend's <img> tag is gated on entry.thumbnail_path being
truthy, so the next fetch of the log list skips the request entirely.
2. Eager clear on archive delete: new _null_print_log_thumbnail_paths()
helper called from both soft_delete_archive and delete_archive before
the on-disk files are removed. Avoids the one-time storm for future
deletes; covers both the manual delete route and the auto-purge
sweeper at archive_purge.py.
Regression tests cover soft delete, hard delete via ArchiveService, and
the route's lazy-NULL for failed-print orphans where the file was never
written.
Obico polling could freeze the live camera stream within seconds of opening
the viewer. Cause: when the buffer-reuse path in obico_detection._capture_frame
saw an empty _last_frames[printer_id] entry (stream startup before the first
JPEG lands, or upstream mid-reconnect after a 30s read timeout), it fell
through to capture_camera_frame_bytes() and opened a second RTSP socket. On
firmwares that allow only one camera connection, that second socket forced
the printer to drop the live fan-out connection - the viewer's ffmpeg then
hit its own 30s timeout, looped through 30 reconnects at 0.2s, all racing
the next Obico poll, and the broadcaster pump exited.
Widen the gate from "do we have a buffered frame?" to "is any fan-out stream
registered for this printer?". New is_stream_active() helper checks
_active_streams / _active_chamber_streams independently of buffer state.
_capture_frame consults it first: if a viewer is attached, it returns the
buffered frame when available or None (skip this poll cycle) when not. Never
opens a competing socket while a viewer is connected.
Cost: at most one missed Obico detection cycle per viewer-attach (~10s lag).
Benefit: zero competing-socket events while any viewer is connected.
try_get_active_buffered_frame() refactored to delegate to is_stream_active()
so the two helpers stay in lockstep. The /camera/snapshot caller is unchanged
behaviorally (snapshot is a user-initiated single-shot; falling through to
fresh capture on an empty buffer is the desired behavior there).
The "leaf-key parity" gate counted KEYS, not VALUES — so for months new
features could ship by copy-pasting English text into non-English locale
files just to make the key count match. ~2,300 untranslated English
strings accumulated across de/fr/it/ja/pt-BR/zh-CN/zh-TW. Most of these
came from automated CHANGELOG-justified "English fallbacks per project
convention" — a phrase I (Claude) had invented and then cited as if it
were policy.
Two structural fixes so it can't happen again:
1. New Check 4 in check-i18n-parity.mjs flags any leaf whose value is
identical to en.ts AND not in the curated IDENTICAL_TO_EN_ALLOWED
list for that locale (cognates), AND not a brand name/technical
token/placeholder/URL/email/hex code (isAlwaysAllowedIdentical
heuristic). Future English-into-non-English shortcuts fail CI loudly.
2. Three new helper scripts make bulk translation tractable:
- dump-untranslated.mjs: list every flagged (locale, key)
- expand-translations.mjs: unique-source table → per-(locale, key) JSON
- apply-translations.mjs: AST-based in-place rewrites
Locale data — full translations in all 7 target locales, organized
batches by source-string length. Cognates that legitimately match en
(Status/Firmware/Tag/etc. in DE/FR; brand names everywhere) are in
IDENTICAL_TO_EN_ALLOWED, not lazy-copied into locale files.
Statistics now aggregate over PrintLogEntry (one row per print event,
the same table backing the global Print Log) rather than PrintArchive
(one row per file). A reprint creates a new PrintLogEntry instead of
overwriting the source archive's runtime fields, so:
- a 100 g successful print + a 10 g failed reprint correctly sums to
110 g / 2 prints / 1 successful / 1 failed in Quick Stats and the
Prometheus /metrics endpoint (previously the failed reprint silently
replaced the source archive's data; totals dropped from 100 g to 10 g)
- the archive's card cost/energy_kwh are preserved on reprints (only
the first run writes them); per-run actuals live on PrintLogEntry
- failed/cancelled/stopped reprints record partial-aware filament: sum
of tracked spool deltas when inventory is set up, else estimate
scaled to progress%, else None — prevents the full slicer estimate
from inflating totals on a print that stopped at 10 % progress
PrintLogEntry gains six columns: archive_id (nullable FK, ON DELETE
SET NULL so log entries survive archive deletion preserving #1343
soft-delete-vs-stats decoupling), cost, energy_kwh, energy_cost,
failure_reason, created_by_id. Idempotent SQLite + Postgres migrations.
New per-archive surface:
- archive list response carries run_count / last_run_at /
total_filament_actual_grams / successful_run_count / failed_run_count
via a single batch JOIN, no N+1
- new GET /archives/{id}/runs endpoint returns every PrintLogEntry for
the archive (ARCHIVES_READ permission, newest-first ordering)
- archive cards render an orange "N prints" badge for archives with
more than one run; clicking the badge opens a dedicated PrintLogModal
with date/status/duration/filament/cost columns plus failure_reason
under failed runs. Also reachable via the context menu's new "Print
Log" entry (works for single-run archives too), and embedded at the
top of the Edit Archive modal for context.
The purge_stats=true delete path now hard-deletes linked PrintLogEntry
rows up front so the archive's contribution truly leaves the totals;
without it, ON DELETE SET NULL would orphan the runs and leave them
counting toward stats.
Matplotlib (imported lazily by stl_thumbnail.py) tried to create its
font/style cache at $HOME/.config/matplotlib on first STL upload.
HOME=/app per the Dockerfile but /app is root-owned and not writable
by the PUID:PGID the entrypoint drops to, so matplotlib logged
"Permission denied" and fell back to /tmp/matplotlib-* — wiped on
every restart, paying the font-scan cost again on the next STL.
Add ENV MPLCONFIGDIR=/tmp/matplotlib to make the cache directory
writable and persistent across the container's lifetime. /tmp is
writable by any uid, so this works regardless of PUID.
The bridge cache replaced _latest_print_state wholesale on every
push_status arrival. Bambu firmware sends full pushall responses
(with AMS/vt_tray/net.info/lights_report) on reconnect / pushall
requests, but ~1 Hz incremental updates with only the fields that
changed. The first incremental push after a pushall therefore wiped
AMS info from the bridge cache, and slicers reading the cache (via
the VP's 1 Hz status push) saw a stripped-down state with no AMS
visible until the next pushall — typically only on a manual printer
power-cycle.
Preserve a small set of slicer-visible sticky keys from the previous
cache when the incoming push doesn't carry them: ams, vt_tray,
ams_extruder_map, mapping, net, ipcam, lights_report. Mirrors the
same pattern Bambuddy uses for its own internal state.raw_data.
Both the queue-side _watchdog_print_start and the direct-dispatch
_verify_print_response used `status.state != pre_state` to decide
whether a project_file command had been accepted. When a printer was
in FINISH at dispatch time (un-dismissed post-print prompt from a
prior job), the firmware silently rejected the new command; if the
user then dismissed the screen prompt, the printer moved FINISH ->
IDLE and the watchdog returned early as "command landed" — leaving
the queue row stuck at status='printing' indefinitely and the
scheduler permanently marking the printer as busy.
Narrow the "command landed" check in both verifiers to an allow-list
of active-print states (PREPARE / SLICING / RUNNING / PAUSE).
Inactive transitions (FINISH -> IDLE, etc.) no longer short-circuit
the revert. The subtask_id-advance signal stays in place for H2D's
slow FINISH -> PREPARE transition (#1078).
Also wrap _watchdog_print_start's revert commit and
printer_manager._persist_awaiting_plate_clear in run_with_retry so
SQLite single-writer contention can't silently drop these writes.
The revert path returns a tristate sentinel so the post-revert MQTT
session-recovery logic only runs when we actually reverted (or the
commit failed) — not when on_print_complete had already cleared the
row, where a forced reconnect could break a healthy concurrent print.
The #765 guard against shutdown-time data wipes skipped any AMS update
with power_on_flag=False, but some X1C firmware emits power_on_flag=False
while idle with tray_exist_bits still reflecting the real slot inventory.
Older firmware (01.08.02.00) doesn't emit per-tray state=9/10 events, so
the bitfield path is the only signal — muting it left spool removals
undetected until a manual reconnect.
Narrow the skip to the exact shutdown pattern: zero bits AND
power_on_flag=False. Non-zero bits with power_on_flag=False are now
applied. The #765 shutdown protection is preserved (its regression test
uses tray_exist_bits='0' and still passes); newer firmwares are
unaffected because their per-tray state path catches the removal first.
Discord's "Copy Webhook URL" button emits discordapp.com URLs; both
hostnames serve the same webhooks. The validation now accepts either
prefix while keeping the check itself in place to catch the
paste-the-wrong-thing error.
archives stop reporting near-zero cost (#1344)
Reporter @nicktags hit $0.01 on a 110.3g multi-color print with the
global default filament cost set to $10/kg. archive.py initial cost
calc was correct (~$1.10), then usage_tracker.on_print_complete
overwrote archive.cost with sum(r.cost for r in results) -- where
results only includes AMS trays mapped to a spool in Bambuddy's
inventory. On a multi-color print where 3 of 4 used trays had no
inventory spool, only the one tracked slot's tiny share (~1g) survived
and the archive recorded $0.01.
The overwrite logic dates to #505 (Feb 2026) and is correct for
fully-tracked single-color prints, but the multi-color slicer feature
in 0.2.4 (988c0055) made the partial-inventory state common -- users
slice + print multi-color from Bambuddy without first setting up an
inventory entry for every tray.
Cover the gap: any filament weight not represented in results gets
charged at the global default rate. For a fully-tracked print,
untracked grams = 0 and the top-up adds nothing, so the single-color
behavior is preserved. For a partial print, the missing slots are
priced at the user's documented default rate so the archive cost
reflects the whole print.
Three call sites updated to share the same logic:
- usage_tracker.py: live cost-update on print complete
- archives.py rescan_archive: per-archive manual recalc
- archives.py recalculate_all_costs: bulk recalc button
Reporter @Andlar94 hit a permanent "Build plate not empty" on every print
start on an A1 with an external RTSP camera. The runtime auto-check at
main.py:1819 called check_plate_empty with use_external=external_camera_enabled,
but the manual UI routes (camera.py) declared use_external: bool = False
and the frontend client always sent use_external=false. So calibration
captured a built-in frame and stamped it as the reference; the runtime
check captured an external frame and diffed it against that reference --
a permanent mismatch.
Centralise the default on the backend: both routes now take bool | None,
deriving the default from the printer's external_camera_enabled +
external_camera_url + external_camera_type. The frontend client stops
sending the flag unless the caller explicitly sets it, so the existing
UI call sites immediately benefit and any future caller gets the right
camera automatically. Explicit overrides still win.
Adds 4 regression tests pinning the new default for both the
external-enabled and external-disabled cases, plus the explicit override
path so a future "always built-in" caller stays supported.
Reporter @maziggy followed the Energy Tracking wiki literally - "create a
key with Write Settings permission, PATCH /api/v1/settings with
{energy_cost_per_kwh: ...}" - and hit:
{"detail":"API keys cannot be used for administrative operations"}.
Triage showed three independent drifts:
1. Wiki listed nine fictional API-key permissions (Read Printers / Write
Settings / Admin / ...) but the UI only ever exposed four toggles
(Read Status, Manage Queue, Control Printer, Allow Cloud Access).
There was no Write Settings toggle to tick.
2. Even if it had existed, the backend hard-denies SETTINGS_UPDATE for
every API key via _APIKEY_DENIED_PERMISSIONS - intentional protection
because PATCH /settings can rewrite SMTP/LDAP/MQTT credentials and the
HA access token. Wider surface than any documented use case needs.
3. So the wiki had been promising a workflow that was never deliverable.
Fix: introduce a narrowly-scoped door rather than relax the deny list.
- New column can_update_energy_cost (default FALSE - existing keys
never silently gain settings-write capability on upgrade).
- New route POST /api/v1/settings/electricity-price accepting
{"energy_cost_per_kwh": <float >= 0>}. Field name matches what the
wiki already documented so the HA rest_command example needs only a
URL+method change, not a payload change.
- Custom dependency require_energy_cost_update() bypasses
_APIKEY_DENIED_PERMISSIONS for this one route for API keys with the
flag set. JWT users still go through standard SETTINGS_UPDATE.
- General PATCH /settings remains denied for API keys - flipping the
narrow flag does NOT widen general settings-write access. Pinned by
test_patch_settings_still_denied_with_energy_flag.
Frontend: fifth "Update electricity price" toggle on the create-API-key
card + amber "Energy" badge on existing keys with the flag set. Three
new i18n keys across all 8 locales (German translated, English fallbacks
elsewhere).
Reporter @Andlar94 ran the external-camera flow on an A1 dispatched via the
print queue and got no MP4 output even though the log said "Stitching layer
timelapse for printer 1" after each print. Support bundle confirmed the
external camera was working (Obico was polling the snapshot URL fine for
plate detection).
Root cause: start_session() only ran in the two new-archive paths in
on_print_start (fallback_archive at main.py:2510 and regular new-archive at
2600). The expected-archive branch at main.py:1981-2052 — where every
reprint and every queue/VP-dispatched print lands — updated the existing
archive row to status=printing but never started a timelapse session.
So _background_layer_timelapse ran at print complete, called tl_complete(),
found nothing in _active_sessions, returned None silently, and the wrapper
at main.py:3917 produced no log message for the no-session case. Every
print through the queue silently lost its timelapse — likely the reason
this hasn't been caught before (direct slice-and-send-to-printer prints
take the new-archive path and work fine).
Fix: mirror the same start_session() call in the expected-archive branch,
guarded by the same external_camera_enabled + external_camera_url check the
other two paths use.
Also reworded the snapshot URL help text across all 8 locales to make clear
that timelapse and plate detection each require their own per-printer
toggle — the URL is just the image source they pull from when active. The
previous wording read as if filling in the URL was sufficient.
Follow-up to the #1322 root fix. Reporter @RosdasHH traced the raw MQTT
payload and found that P1S and A1 Mini send only {"id": N} for a
physically empty slot — no state, no tray_type, no other fields. Without
that signal, the assign-spool path was firing one wasted MQTT publish per
click on a truly-empty slot (firmware dropped it silently, but still).
The AMS parser in printer_manager.py now detects the bare-tray shape and
promotes it to state=9 — the firmware's explicit "no spool" code — which
lets the existing state in {9, 10} short-circuit in the inventory route
apply automatically.
The detection is intentionally narrow:
len(tray) == 1 and "id" in tray and state is None
so the post-Reset-Slot A1 Mini BMCU case (populated payload with state=3
and tray_type="") has more than one key and stays unaffected — the #1322
root fix is preserved.
Add `# pragma: allowlist secret` markers to the three lines GitGuardian
flagged (ldap_server_url, ldap_bind_dn, admin_password) and pull the
duplicated AdminPass1! literal into a single test_password variable so
the marker only needs to live in one place.
All values are test fixtures (test directory + admin password used only
by the LDAP provisioning integration suite), not real credentials.
Reporter @Fuechslein flagged that disabling LDAP auto-provision left admins
with no UI path to onboard new users — the create-user form had zero LDAP
awareness and the only workaround was hand-editing the database.
Add a Local / LDAP tab toggle to the create-user modal (hidden when LDAP is
disabled). The LDAP tab is a debounced directory search (≥2 chars, 300ms)
that returns up to 25 matches via the service-account bind, annotated with
already_provisioned so existing usernames render disabled. Clicking
"Provision user" re-resolves via the service bind and creates the user
through the same _provision_ldap_user helper the auto-provision login path
uses, so group mapping, default-group fallback, and email sync are identical
regardless of which path created the user.
The picker component is shared across all four create-user modal paths
(UsersPage basic + advanced, SettingsPage basic + advanced).
Two ldap3 schema-check workarounds were needed for OpenLDAP installs:
- Open the search connection with check_names=False so ldap3 doesn't reject
the cross-schema OR filter (sAMAccountName/displayName are AD-only)
- Request attributes=["*"] because ldap3's build_attribute_selection
validates each named attribute against the server schema regardless of
check_names, and only the * wildcard is in its hard-coded exclusion list
Login/lookup paths keep check_names=True so typos in user_filter still fail
loudly.
Backend
- New routes: GET /auth/ldap/search, POST /auth/ldap/provision (both gated
by USERS_CREATE; 503 details include ldap3 exception class + message)
- Extract _open_service_connection + _extract_user_info helpers so
authenticate_ldap_user, lookup_ldap_user, and search_ldap_users share the
bind and attribute-extraction logic
Frontend
- New LdapUserPicker component (debounced search, result list, provision
mutation, already-provisioned guard, error surface)
- Tab toggle wired into UsersPage and SettingsPage modals, plus
CreateUserAdvancedAuthModal props
- 14 i18n keys added to en.ts (other locales fall back to English)
The original #1322 fix widened empty-slot detection to (state == 11 OR
tray_type != ""), which closed the configured-slot reconfig case but
didn't help the "Reset Slot on printer screen with spool still inserted"
flow. On these firmwares the AMS reports state=3, tray_type="" after a
Reset Slot regardless of whether a spool is physically present, so the
empty-detection still decided "empty", skipped MQTT, marked pending —
and on_ams_change replay never re-fired because the AMS never reported
any state change either.
RosdasHH traced the path: tray_state=3 falls into the else: branch,
slot_is_empty = not (fingerprint_type and fingerprint_type.strip()),
fingerprint_type is "", so slot_is_empty=True, MQTT is skipped, and the
slot stays unconfigured forever. He verified empirically that removing
the gate makes the firmware accept the push when a spool is physically
present.
Drop the tray_type fallback entirely. Only state in {9, 10} (firmware's
explicit "no spool" / "spool present but no feed") short-circuits the
MQTT publish. Every other state — including 3 (default-idle, ambiguous)
and missing-state (older firmwares) — attempts the publish. Bambu's
"firmware silently drops on empty slots" behavior makes the worst case
a no-op for a truly-empty slot, and on_ams_change replay still serves
as the safety net for state=9/10 slots whose spools get inserted later.
pending_config is now (slot_is_definitely_empty OR not configured) so a
printer-offline / no-client publish failure correctly flags the
assignment for replay instead of falsely showing "configured".
The firmware update dialog showed "01.11.02.00 newer · Unavailable" with the
misleading error "Firmware file is not available from Bambu Lab" while the
logs spammed "Failed to get Bambu Lab page: 403". The wiki scrape was fine —
only the Next.js buildId fetch on bambulab.com was being blocked by Cloudflare
on the reporter's network, and the buildId was cached in memory only, so a
single 403 broke download-URL resolution for the rest of the session.
- Send Accept + Accept-Language headers alongside the honest Bambuddy/1.0 UA
so the request stops tripping Cloudflare's "bare scraper" signal.
- Persist the buildId to <data_dir>/firmware/build_id.json so a transient
403 or a backend restart can't wipe a previously-valid buildId.
- Add a download_page_unreachable flag and use it in the prepare-update flow
to render an honest error ("page unreachable from this network — try later
or download manually from bambulab.com") instead of implying Bambu doesn't
have the file.
- Retry the per-model JSON once when a cached buildId returns 404 (page
rebuild), give up gracefully on 403 without churning.
Adding a third-party PETG-CF spool via the Material=PETG + Subtype=CF
flow (same shape as the existing PETG HF) hit a missing option: KNOWN_VARIANTS
in spool-form/constants.ts didn't list CF or GF. Users had to type it
freehand into the "create new" tail of the dropdown.
Added both: CF (matches PETG-CF / PLA-CF / ASA-CF / PA-CF) and GF (the
natural pair for ABS-GF / PA6-GF). parsePresetName is unaffected — its
materials list is iterated longest-first, so cloud presets like
"Bambu PETG-CF Black" still resolve to material=PETG-CF with empty
afterMaterial.
Two latent issues surfaced after the original AssignSpoolModal z-50 →
z-[100] bump landed:
1. Material-mismatch ConfirmModal hidden behind AssignSpoolModal.
ConfirmModal's overlay was hardcoded to z-50 in its wrapper, so once
the parent moved to z-[100] the nested confirmation dialog sat
behind it. Added an optional overlayZIndex prop to ConfirmModal
(defaults to z-50 — none of the 82 other call sites change), and
the mismatch site in AssignSpoolModal passes z-[110] so the warning
stacks above its parent.
2. FilamentHoverCard / EmptySlotHoverCard covered by sibling printer
cards on the dashboard. The popovers used position:absolute with
z-[60] inside the trigger, but every printer card creates its own
stacking context (drop-shadow filter on the slot tiles is enough),
and z-index doesn't cross stacking-context boundaries — the next
sibling card always wins by DOM order. Visible as the "Jade White
· Bambu PETG HF" tooltip getting half-eaten by the neighbour card's
AMS column.
Fixed by portaling both hover cards to document.body with
position:fixed and screen-space coordinates from
triggerRef.getBoundingClientRect(). Coords recompute on visibility
change, scroll (capture), and resize so the popover follows the
trigger when the viewport moves; a requestAnimationFrame re-measure
after the first paint avoids a one-frame flicker before the card
has its rendered dimensions. Hover handlers are wired on both the
trigger AND the portaled card so moving the cursor from slot to
popover doesn't auto-dismiss after 100 ms. Top/bottom placement
and arrow-pointer logic preserved.
Reported by @IndividualGhost1905: printing the same model ten times and
then deleting nine archive entries (to keep the file list tidy) silently
rewound the totals on the Statistics page — total prints, filament,
cost, and per-print energy all dropped back to whatever the surviving
row contributed, as if the other nine prints had never happened.
Root cause: every metric in get_archive_stats is recomputed live from
PrintArchive rows via COUNT / SUM, so removing a row removes its
contribution. Energy in the default "Total" mode already survived
deletion because it reads the smart-plug lifetime counters — that's
the architectural shape we now generalise to the rest.
Fix: soft delete with opt-in hard purge.
Backend:
- New nullable, indexed deleted_at column on print_archives, dialect-
conditional migration (DATETIME on SQLite, TIMESTAMP on PostgreSQL).
- ArchiveService.soft_delete_archive flips deleted_at and removes the
files from disk (still reclaims storage); the path-safety checks were
extracted into _resolve_archive_dir_for_delete so soft and hard delete
share the rules.
- DELETE /archives/{id} accepts ?purge_stats=true; default is soft.
- Listings filter deleted_at IS NULL: list_archives, search FTS + LIKE
fallback, GET /{id} (404 on soft-deleted), tag listing, duplicate
detection (so a 1-live + 9-soft-deleted group no longer marks the
survivor as a duplicate), and ArchiveComparisonService's "similar"
suggestions. GET /stats and GET /slim deliberately do NOT filter so
Quick Stats and the dashboard widgets keep counting deleted prints.
Frontend:
- ConfirmModal gained an optional children slot.
- ArchivesPage (both card and detail views) own a per-instance
deletePurgeStats boolean and render an opt-in checkbox in the delete
dialog; resets to off on every close so the destructive option is
never sticky.
- api.deleteArchive(id, purgeStats?) appends ?purge_stats=true only
when the box is ticked.
- One new i18n key archives.modal.deletePurgeStats added across all 8
locales (full German, English fallbacks elsewhere).
* feat(auth): proxy OIDC provider icons server-side (#1333)
Strict img-src CSP blocked external OIDC icon hosts on the login page.
Loosening CSP was rejected via the MakerWorld precedent, so icons are
proxied: admin sets icon_url, backend fetches and caches the bytes in a
deferred BLOB column, the SPA renders from a same-origin
/api/v1/auth/oidc/providers/{id}/icon endpoint.
The assign flow was sending slicer-invalid values for tray_info_idx and an
empty setting_id, which the slicer rejected — slot detail modal showed
empty fields. With a stored k-profile the realignment path masked the
issue; without one, garbage hit MQTT.
Backend (apply_spool_to_slot_via_mqtt):
- Discard tray_info_idx values that aren't real preset IDs: literal
material names ("PLA", "PETG-CF") AND PFUS-prefix cloud setting_ids
(valid as setting_id but rejected as tray_info_idx). Same check applied
to current_tray_info_idx so stale slot values don't get reused as
garbage.
- Local-preset path now reads the printer-recognized filament_id from
the preset's setting JSON (e.g. P4d64437) instead of falling through
to a generic material ID.
- Derive setting_id from filament_id_to_setting_id when empty so
ams_filament_setting always carries a matched pair.
- No stored k-profile: always send cali_idx=-1 (Default K), regardless
of the live cali_idx on the slot. The live value belongs to whatever
filament was there before, so reusing it would apply the wrong K to
the new spool.
Frontend (spool-form/utils.ts):
- Local preset options use String(preset.id) as the unique code instead
of preset.filament_type — every PLA local preset was collapsing onto
the same "PLA" code, so picking any of them saved slicer_filament=
"PLA" and lost the specific preset identity.
Spoolman counterpart in spoolman_inventory.py mirrors the cali_idx=-1
reset.
Both used z-50, so DOM stacking-order let the sidebar bleed over the
modal on narrow viewports. Bumped to z-[100] to match the convention
used by GitHubBackupSettings, FilamentHoverCard, and the Layout
confirmation modal.
Slicing an STL via the integrated slicer always defaulted to whatever
curr_bed_type lived in the chosen process preset (typically "Cool
Plate"), which the slicer CLI rejected for high-temp filaments with
"Plate 1: Cool Plate does not support filament 1". The user had no
way to switch plates without cloning the preset in BambuStudio.
The Slice modal now exposes a Build plate dropdown with the six
canonical BambuStudio / OrcaSlicer plates (Cool Plate, Cool Plate
SuperTack, Engineering Plate, High Temp Plate, Textured PEI Plate,
Smooth PEI Plate) plus an "Auto (use process preset)" option that
preserves the previous behavior. Positioned between Process profile
and Filament rows so a long filament list never pushes it off the
modal's scrolled viewport, and always enabled regardless of whether
the user picked a Printer Preset Bundle.
A new bed_type field on SliceRequest flows through both dispatch
paths:
- Resolved-preset path: _patch_process_bed_type overwrites
curr_bed_type on the process JSON before forwarding to the sidecar.
Works end-to-end today, no sidecar change needed.
- Bundle dispatch path: slice_with_bundle adds a bedType form field
to the sidecar multipart. The sidecar (maziggy/orca-slicer-api
fork) needs a matching change to honor it as --curr_bed_type on
the CLI invocation; until then the field is silently ignored and
the slice runs with the bundle's default plate.
Picking a color preset from the catalog only copied color_name and
rgba onto the spool — extra_colors (gradient stops) and effect_type
(sparkle / wood / etc.) were silently dropped at three layers above
the API: the SpoolFormModal state shape, the CatalogDisplayColor
mapping in ColorSection, and the selectColor handler itself. All
three widened to carry both fields through.
Picking a catalog swatch now writes both fields from the entry, so
solid presets cleanly replace previous gradients. Recent-colors and
the hardcoded-fallback palette stay as plain hex pickers — they
don't touch extras/effect since they aren't full presets.
Also fixed the en-US `colour` → `color` drift in 8 locale files
that the reporter flagged.
A1 Mini BMCU (01.07.02.00) and P1S Standard AMS (00.00.06.75) always
report tray.state=3, even for loaded configured slots. The empty-slot
detection preferred state==11 with tray_type as a fallback only when
state was absent, so every assign was classified as empty and MQTT
was skipped — both for "assign to unconfigured slot" and the secondary
"PETG over a PLA-configured slot won't reconfigure" symptom.
Empty-slot detection in the assign route and the on_ams_change replay
now treats the slot as loaded when EITHER state==11 OR tray_type is
non-empty. Reset-slot case (state=11 + tray_type="") still works
through the first clause; configured slots on these firmwares now
work through the second.
Truly empty unconfigured slots (state!=11 + tray_type="") still hit
the pending-config path, and the deferred publish now fires when the
user later configures the slot in Bambu Studio (tray_type goes
non-empty), since the replay uses the same disjunction.
Two regressions reported in #1336:
1. spoolMatchesQuery did not include spool.id in the predicate, so
typing a numeric Spoolman ID into the Assign Spool dialog or the
Inventory page search returned no matches. Predicate now also
tests String(spool.id).includes(q).
2. The Unassign button in the spool edit modal was permanently
disabled for Spoolman-mode spools. The modal only ever queried
the legacy spool_assignments table (keyed by spool_id), but in
Spoolman mode the assignment lives in spoolman_slot_assignments
(keyed by spoolman_spool_id). Both the lookup query and the
unassign mutation now branch on the spoolmanMode prop and call
the Spoolman-flavored endpoints.