feat(oidc): add Azure Entra ID support with configurable email claim resolution
Adds two new OIDC provider fields: email_claim and require_email_verified.
Two new optional fields on Spool: free-text `category` (max 50) and
`low_stock_threshold_pct` (1-99). Powers the "differentiate critical
spools from prototype spools and alert at different thresholds" use
case from #729 without taking on the full multi-tag taxonomy + auto-
apply rules + per-tag alert system the ticket originally proposed.
Form gains:
- Category input with datalist autocomplete sourced from categories
already in use, so casing/spelling stays consistent.
- Per-spool low-stock threshold input. Empty = global default; the
global value renders as the placeholder.
Inventory page:
- New category filter chip (hidden until at least one spool carries
a category — keeps the chip row uncluttered).
- Stat-card "Low Stock" count and the "Low Stock" filter both honour
the per-spool override.
Plus: rename "Delete Tag" button to "Clear RFID Tag" (the original
ticket reporter mistook it for a taxonomy-tag delete; the button
actually clears the RFID UID/UUID off the spool record). Toast key
renamed from `tagDeleted` to `rfidCleared`.
i18n: full translations across all 8 locales.
Tests: 9 new backend schema tests (defaults, partial-update, range
rejection, max-length); 2 new frontend tests (per-spool threshold
pulls extra spools into low-stock count, filter chip hidden when no
categories exist).
`find_matching_untagged_spool` is supposed to attach an incoming Bambu
RFID UUID to a pre-existing manually-logged spool of the same
material/color so users who log inventory before scanning don't end up
with duplicate rows. Two bugs meant it almost never worked for the
actual reporting workflow:
1. Subtype filter was strict. AMS reports `tray_sub_brands="PLA Basic"`
→ matcher required `Spool.subtype = 'Basic'` exactly. The form's
Quick-Add mode only requires `material`, so bulk-logged rows have
`subtype=NULL` and were always excluded → duplicate on first AMS
read.
2. Brand wasn't filtered. The docstring claimed brand was matched but
the WHERE clause didn't include it, so a same-color Polymaker (or
any non-Bambu) untagged row could acquire a Bambu UUID — silent
data corruption.
Fix in the same query: subtype prefers exact match but accepts NULL as
fallback (CASE in ORDER BY ensures exact wins when both exist); brand
restricted to NULL or LOWER(brand) LIKE '%bambu%' (covers 'Bambu',
'Bambu Lab', 'BambuLab', 'bambu lab' — the spellings users actually
type).
6 regression tests added in test_spool_tag_matcher.py.
ntfy supports a Priority header (1=min, 2=low, 3=default, 4=high, 5=urgent)
that controls escalation on the receiving device, but every event was being
sent at the server default — so a "50% complete" ping looked identical to
"print failed" or "printer offline". Add a per-event priority dropdown
section in the Add/Edit Notification modal (visible only for ntfy, listing
only enabled events); the backend reads config.event_priorities and emits
the matching Priority header on POST and PUT (image-attachment) paths.
Unmapped events fall through to the ntfy server default. Out-of-range
and non-numeric values are dropped, not clamped, so a misconfigured value
never silently sends at the wrong urgency. Test sends omit the header by
design so the test path can't accidentally page someone at urgent priority.
Backward compatible: existing providers without event_priorities behave
exactly as before. NtfyConfig.event_priorities is optional; the route
stores config as a JSON blob so no migration is needed.
i18n: full translations across all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/
zh-TW). README, CHANGELOG, and the wiki notifications page updated.
Tests: 6 backend (Priority set on mapped, omitted on unmapped/missing/
no-priorities, ignored for bad values, propagated through attachment
path), 6 frontend (section visible only for ntfy, lists only enabled
events, save round-trip, edit pre-fill, toggle drops row, non-ntfy
never writes the key).
The global CSP set script-src 'self', so FastAPI's /docs page rendered
blank: the inline boot <script> and the cdn.jsdelivr.net swagger-ui
bundle/CSS were both blocked. /redoc and /docs/oauth2-redirect had the
same problem.
Branch the security_headers_middleware to emit a docs-scoped CSP for
those three paths that allows cdn.jsdelivr.net (scripts + styles), the
FastAPI/Redoc favicon hosts (images), and 'unsafe-inline' for the
inline boot script. Every other route keeps the stricter SPA policy
unchanged.
#1108 — Long-lived camera-stream tokens for HA / Frigate / kiosks. Camera-only
V1, hard 365-day cap (no infinite tokens), pbkdf2 hashed at rest, plaintext
shown to user exactly once on creation. New "Camera API Tokens" panel under
Settings → API Keys with self-service create/revoke, styled confirm modal,
admin "All users" view for leak triage. Auth path: /camera/stream tries the
existing 60-min ephemeral table first, falls through to the long-lived path.
Indexed lookup_prefix keeps verify O(1) per token.
Permission audit: gated the existing API-keys-CRUD + Webhook docs + API
Browser content behind api_keys:read so non-admins with camera:view land on
the API Keys tab and see only the Camera Tokens panel they actually have
permission to use. Grid layout collapses to single column for non-admins.
Tests: 29 new backend (15 service + 14 integration covering create/list/
revoke ownership rules, the auth fall-through, scope enforcement, prefix
collisions) + 6 new frontend tests for the section UI including the new
modal flow. All 77 backend tests + 21 frontend camera tests pass. Ruff
clean (lint + format).
Docs: README updated with fan-out + long-lived-token bullets. Wiki gets a
new "Long-Lived Camera Tokens" section under features/camera.md (HA YAML
example, security model, permission requirements, revoke flow). Website
features.html gets the bullet under Camera Streaming.
Also includes #1089 follow-up tweaks already merged in this branch:
_stream_start_times.setdefault for accurate stream_uptime, subscribe()
RuntimeError retry to close the grace-vs-subscribe race, atomic
unsubscribe count via the iter_subscriber on_unsubscribe callback.
Most Bambu Lab printers only allow one concurrent camera connection, but
GET /printers/{id}/camera/stream opened a fresh upstream per viewer.
Two browser tabs → second viewer fails or kicks the first off.
New MjpegBroadcaster (services/camera_fanout.py) owns one upstream per
printer and fans MJPEG chunks out to N subscribers. 5 s grace window
absorbs tab refreshes without reconnecting. Bounded subscriber queues
drop frames for slow viewers rather than blocking the broadcaster.
Audit-pass fixes:
- _stream_start_times set with setdefault() so stream_uptime reflects
the shared upstream's age, not the most-recent viewer's
- subscribe() retried once on RuntimeError to close a tiny grace race
- unsubscribe() returns post-removal count atomically so the detach log
no longer races with concurrent leavers
Permission gates unchanged; broadcaster has no FastAPI surface.
Tests: 13 broadcaster unit tests + 2 integration tests on /camera/stop.
External-camera path untouched.
POST /library/files only rejected the read-only external branch and
then unconditionally wrote to get_library_files_dir() with a UUID
filename. The resulting LibraryFile row pointed at the external folder
via folder_id, so the file showed up in Bambuddy's UI, but the bytes
physically lived in archive/library/files/ and never touched the mount
-- invisible from any other machine accessing the NAS/SMB share.
Writable external uploads now write through to <external_path>/<filename>
with the original filename preserved, and the DB row matches what scan
produces (is_external=True, file_path=<absolute mount path>). Collisions
return 409 instead of silently overwriting; inaccessible or non-writable
mount returns 400; path-traversal filenames are rejected via resolve +
relative_to.
Extract-zip is now rejected against any external folder (not just
read-only) with a clear "extract on the mount and run Scan" message --
the nested-subfolder creation path would need mkdir on the mount plus
matching is_external LibraryFolder rows, which is a separate design.
Scan already handles that shape.
When a file sliced for the wrong nozzle size is dispatched, the printer
goes IDLE -> PREPARE -> FAILED without ever entering RUNNING. Completion
detection required prev=RUNNING or _was_running=True, so on_print_complete
never fired and the queue item stayed at "printing" forever -- blocking
every subsequent pending item for that printer (check_queue seeds
busy_printers from any row in 'printing').
Fire completion on FAILED from PREPARE or SLICING too. Restricted to
those two pre-print states so a stale FAILED on first connection
(prev=None) still can't accidentally advance an unrelated queue item.
Also populate PrintQueueItem.error_message from the current HMS error
list via the existing hms_errors.py lookup, so users see e.g.
"[0500_4038] The nozzle diameter in sliced file is not consistent
with the current nozzle setting" instead of a blank failure reason.
The SSRF guard added in this PR rejected all RFC-1918 private and loopback
addresses, which breaks Bambuddy's primary deployment topology — Spoolman
running on the same LAN as Bambuddy (192.168.x.x, 10.x.x.x, 127.0.0.1).
Users hit "Spoolman URL must not point to a private, loopback, link-local,
multicast, or unspecified address" on legitimate setups.
Rescope the guard to block what's actually dangerous in this context:
cloud metadata endpoints (AWS/Alibaba IMDS), multicast, unspecified,
non-http(s) schemes, and numeric-encoded IP bypasses. Loopback and
RFC-1918 ranges are now explicitly permitted.
Tests:
- test_ssrf_blocked_schemes_and_addresses updated with refined block list
- test_ssrf_allows_lan_spoolman_topologies (new) asserts loopback +
RFC-1918 are accepted so this regression cannot recur silently
- TestSpoolmanInventorySSRFSpoolBuddyPath parametrize lists trimmed
feat(inventory): replace Spoolman iframe with internal inventory UI
When Spoolman is enabled, the Inventory page now uses the same internal
UI (spool list, create/edit modal, archive, delete, weight sync) backed
by a new proxy layer instead of opening an iframe.
1. `_cancel_restart_task` self-await guard (manager.py:389-413).
stop_server() / stop_proxy() are called from inside
_restart_for_cert_renewal, which runs AS _cert_restart_task.
Cancelling+awaiting self flagged a CancelledError on the next
`await` in stop_server, tearing down old listeners but never
letting start_server run — the VP sat on the expired cert
until the process was manually restarted, silently defeating
auto-renewal. Skip when `task is asyncio.current_task()` and
just clear the reference.
2. Clipboard fallback textarea leak (VirtualPrinterCard.tsx:66-81).
The HTTP fallback created a hidden textarea, called
select() + execCommand('copy'), then removed the textarea.
If select() or execCommand threw, removal never ran and the
textarea leaked into the DOM. Move the removal into `finally`
so it happens regardless of the inner block's outcome.
Regression tests in test_tailscale.py::TestCancelRestartTaskSelfAwait
cover both the self-cancel path (must NOT cancel self) and the
outside-cancel path (must still cancel and await).
Add the Tailscale CLI to the production image and document how to
enable Let's Encrypt cert provisioning for virtual printers from a
Docker-deployed Bambuddy.
- Dockerfile installs `tailscale` from the official Debian repo. Only
the CLI is used at runtime; tailscaled itself stays on the host.
The binary is harmless if the socket isn't mounted — the code logs
an actionable hint and falls back to self-signed certs.
- docker-compose.yml adds a commented-out volume mount for
/var/run/tailscale/tailscaled.sock with inline setup instructions.
- tailscale.py's docker-socket hint now also fires when the binary is
present but the daemon socket is unreachable (i.e. the new Docker
pattern), not just when the binary is missing, so users get the
actionable "mount the socket" message instead of opaque CLI stderr.
Enabling the integration on a Docker host:
1. `curl -fsSL https://tailscale.com/install.sh | sh` on host
2. `sudo tailscale up`
3. `sudo tailscale set --operator=<user>` for the container PUID
4. Uncomment the tailscaled.sock mount in docker-compose.yml
5. `docker compose up -d --force-recreate`
6. Flip the Tailscale toggle on the VP card
Legacy SQLite installs created the `settings` table without a UNIQUE
constraint on `key`. The seed loop's `INSERT OR IGNORE` silently
degraded to a plain INSERT, so every `systemctl restart` added another
row of `advanced_auth_enabled` / `smtp_auth_enabled`. After a handful
of restarts, `scalar_one_or_none()` in is_advanced_auth_enabled() and
similar sites blew up with `MultipleResultsFound`, 500'ing the login
flow.
Run-migrations now deletes dup rows (keeping MIN(id) per key) and
creates the missing `ix_settings_key` unique index before the seed
loop. Both ops are idempotent — fresh installs and Postgres already
have the index, so they no-op.
Bambu started shipping H2C units with a new serial prefix (`31B8B…`
observed on a January 2026 unit) instead of the legacy `094…` shared by
the H2D/H2C/H2S family. Two serial-prefix-driven paths — the K-profile
edit branch in `kprofiles.py` and the delete-K-profile MQTT command in
`bambu_mqtt.py::delete_kprofile` — were silently routing the new units
through the single-nozzle format.
Match on 5 chars (`31B8B`): covers the 3-char model code plus the two
revision bytes, leaving the revision-letter slot free to iterate. This
mirrors the X2D precedent of using a longer-than-3-char prefix when a
single data point can't confirm family reuse.
Runtime dual-nozzle detection via `device.extruder.info` count and
model-string branches (`self.model in ("H2C", "H2D", …)`) are already
prefix-agnostic — no change needed there.
- backend/app/api/routes/kprofiles.py: add "31B8B" to is_h2d tuple
- backend/app/services/bambu_mqtt.py: same in delete_kprofile
- backend/tests/unit/services/test_bambu_mqtt.py: regression test
`test_h2c_new_prefix_uses_dual_nozzle_format`
Fix a silent correctness bug: archive purge used `created_at` which is
pinned to the first print, so reprinting a two-year-old archive yesterday
would still make it eligible for a 365-day purge. The preview and purge
queries now age each archive by `COALESCE(completed_at, started_at,
created_at)` — reprints refresh the clock.
Also flesh out both purge modals (File Manager + Archives) with an
explicit "What happens when you click Purge" effects list so users see
upfront that library files go to Trash (reversible) while archives are
hard-deleted (irreversible), plus what disk artefacts get removed.
Backend:
- services/archive_purge.py: `_last_activity_expr()` helper used by
preview, purge, and sample query
- tests/integration/test_archive_purge_api.py: new test covering the
reprinted-archive case
Frontend:
- PurgeOldFilesModal / PurgeArchivesModal: new effects bullet list
- i18n: reprint-aware ageLabel/description/warning and effects bullets
across all 8 locales (en/de fully translated, rest English fallback)
Docs:
- wiki/features/archiving.md: "How old is measured" note + effects list
- wiki/features/file-manager.md: "What happens when you click Purge"
section + explicit age-rule breakdown
- CHANGELOG: archive auto-purge entry rewritten to mention reprint
semantics, `archives:purge` permission backfill, and updated test count
Adds an archive counterpart to the library trash sweeper shipped in the
previous commit. Unlike the library flow, archives are hard-deleted —
print history is a decaying timeline, so there is no trash intermediate;
download or favourite anything you want to keep first.
Backend
- New ArchivePurgeService (backend/app/services/archive_purge.py) with
its own 15-minute scheduler loop and a 24h throttle on actual purge
runs. Delegates every delete to the existing safety-checked
ArchiveService.delete_archive so the 3MF, thumbnail, timelapse, source
3MF, F3D, and photo folder all get cleaned up together with the DB
row. Per-row session via async_session() avoids commit-per-row churn
on any caller-passed session.
- New /archives/purge/{preview,settings} + POST /archives/purge routes
gated on a dedicated archives:purge permission (not archives:delete_all)
so admins can delegate bulk-delete to a role without granting
per-archive delete on other users' rows.
- seed_default_groups() now backfills both library:purge and
archives:purge on the Administrators group for upgraded installs —
the original library:purge was added after Administrators was first
seeded so the "create if not exists" path skipped existing DBs and
left admins without the permission.
- 8 new integration tests (defaults, settings roundtrip, bound
validation, preview, manual purge, auto-purge enabled path, 24h
throttle, disabled skip).
Frontend
- Settings → Archives card gains an auto-purge toggle + age input (7d
floor, 10y ceiling, 365d default), with a save-toast on every change.
The bulk "Purge old" button lives on the Archives page header
(rightmost, after Upload 3MF) to match the File Manager pattern —
configuration in Settings, one-shot action on the page.
- New PurgeArchivesModal mirrors PurgeOldFilesModal: live preview (count
+ total size freed + sample filenames) debounced at 300ms, amber
"hard-delete, no undo" warning.
- Admin-only UI gates on archives:purge via the standard hasPermission
hook; Permission TS union updated.
- i18n blocks across all 8 locales (en/de full, other 6 English
fallback per project convention).
Docs
- CHANGELOG entry under 0.2.4b1 following the existing library-trash
entry.
- bambuddy-wiki archiving.md gains a new "Auto-Purge" section.
- bambuddy-website features.html gets a matching bullet.
Verification: python -m ruff check backend/app/ clean; 25 integration
tests pass (8 archive_purge + 17 library_trash regression); npm run
build clean.
Library files now move to a configurable-retention trash bin on delete
instead of being hard-deleted from disk (default 30 days). Admins get a
"Purge old" bulk action on the File Manager with a live preview, plus an
optional auto-purge setting in Settings → File Manager that runs the same
operation once per 24h when enabled (default off). Regular users see and
manage their own trashed files; admins see everyone's. External (linked)
files bypass trash since their bytes aren't under Bambuddy's control.
- New `library:purge` permission (admin-only by default)
- Nullable indexed `deleted_at` column on library_files; dialect-aware
ALTER TABLE so the column actually gets added on PostgreSQL (raw
DATETIME is SQLite-only syntax)
- New `LibraryFile.active()` classmethod; every query site routed through
it so trashed rows don't leak into listings, print dispatch, MakerWorld
dedupe, or stats
- Trash page: select-all + bulk restore/delete, per-row checkboxes, wider
layout so datetime columns don't clip
- Auto-purge: 24h throttle via `library_auto_purge_last_run` setting so
the 15-minute sweeper cadence still runs the purge at most once per day
- Save toast wired into every trash/auto-purge setting change
- 17 new backend integration tests (service + routes + auto-purge throttle),
8 new frontend tests, localised across all 8 UI languages
- Wiki + website feature entries updated
Reprint from Archive kept showing `created_by_id = NULL` even after the
Direct Print / File Manager / Library attribution fixes in 0.2.4b1.
Root cause: reprint reuses the source archive row (via
register_expected_print → _expected_prints lookup) to avoid duplicate
archives. When the source was auto-created from a printer-initiated
print, its created_by_id was NULL — and reprint never touched it.
Print Log correctly attributed the reprinter (set_current_print_user
→ _print_user_info at print-complete), but the Statistics per-user
filter reads archive.created_by_id and stayed unassigned forever.
Fix in main.py's print-complete handler: when the archive's
created_by_id is NULL and a print-session user is known, back-fill
from _print_user_info. Never overwrites existing attribution — the
original uploader keeps ownership; only NULLs are filled.
Already-completed archives stay NULL (no retroactive rewrite). Next
print after deploy credits the current user on any NULL archive.
* feat(makerworld): URL-paste import and print for MakerWorld models
Add a dedicated /makerworld sidebar page where users paste a MakerWorld
model URL and get the full plate list + one-click "Import to Library" or
"Print Now". Closes the workflow gap that kept LAN-only users on the
Bambu Handy app solely for MakerWorld download-and-send.
The authenticated tier reuses the existing Bambu Cloud token that
Bambuddy already stores for firmware checks and slicer settings --
MakerWorld shares the same auth backend, so the same JWT works there.
No separate OAuth flow, no companion browser extension, no credential
hijack. Anonymous users can still paste a URL and see model metadata;
the 3MF download itself requires the Cloud login.
Print Now hands off to the existing PrintModal (plate picker + AMS
mapping + dispatch) so multi-filament models work via the same code
path as library-file prints. Imported 3MFs are stored through a new
shared save_3mf_bytes_to_library() helper so the multipart upload
route and the MakerWorld import route don't duplicate 3MF parsing +
thumbnail extraction logic.
LibraryFile gains indexed source_type + source_url columns. Re-pasting
a URL for a model already in the library returns the existing row
instead of re-downloading -- dedupe is by canonicalised URL, not SHA256,
because MakerWorld's download URLs are signed and change per request.
Thumbnail proxy (/makerworld/thumbnail) hot-links through the backend
instead of directly to makerworld.bblmw.com -- the SPA's img-src CSP
stays strict and users' IPs don't hit MakerWorld's CDN logs. The
endpoint is intentionally unauthenticated since <img> tags can't carry
a Bearer token; SSRF-guarded by a CDN host allowlist so it can't be
used as a generic proxy.
Search and browse-catalogue are explicitly out of scope. The public
design/search endpoint returns empty results from server-originated
requests (likely needs csrf/session state reproducible only from a
real browser), and the __NEXT_DATA__ HTML fallback is blocked by
Cloudflare. URL-paste covers the realistic discovery pattern (Reddit /
YouTube / shared links).
Headers match kloshi-io/makerworld-api-reverse's production-tested set
(User-Agent: 3d-printing-service/1.0, x-bbl-* client identifiers,
Referer). The /instance/{id}/f3mf call includes ?type=download which
community userscripts use to signal legitimate download intent. 418
responses (MakerWorld's CAPTCHA gate) retry once with backoff and then
surface a clear actionable error with an "Open on MakerWorld" fallback
link; we never try to evade bot detection.
Permissions: new makerworld:view (browse metadata, view thumbnails) and
makerworld:import (save 3MFs to library). Administrators and Operators
get both; Viewers get view-only. Migration grants these to existing
groups based on whether they already have library:upload / library:read.
Disclaimer in the UI and wiki page mirrors kloshi's framing: not
affiliated with or endorsed by MakerWorld or Bambu Lab, interoperability
only, not intended to circumvent access controls.
Tests: 30 backend (service + routes) + 4 frontend. Full backend suite
(1931 tests) clean. Frontend build clean.
* feat(makerworld): ship working URL-paste import via api.bambulab.com iot-service
The MakerWorld integration shipped in 0.2.4b1 dev was broken for most
public models: the makerworld.com/design-service path returns "Please
log in to download models" even with a valid Bambu Cloud bearer,
because it's cookie-gated behind Cloudflare. Published reverse-
engineering projects work around this by pasting browser cookies; we
route around it entirely by using the api.bambulab.com/iot-service
endpoint (documented by Pr0zak/YASTL#51), which accepts the same
bearer Bambuddy already has and returns a presigned S3 URL.
Working flow:
GET api.bambulab.com/v1/design-service/design/{id} → metadata
GET api.bambulab.com/v1/iot-service/api/user/profile/{pid}?model_id=<str>
Authorization: Bearer {cloud_token} → signed S3 URL
urllib.request (no redirects, no query re-encoding) → bytes
Notes on each step:
- The model_id query param is the alphanumeric string from the
design response (e.g. US2bb73b106683e5), NOT the integer designId
from the /models/{N} URL. The import route fetches design metadata
first to get it.
- S3 presigned URLs MUST be fetched with urllib (not httpx/curl_cffi)
because the signature is computed over exact query-string bytes;
any normalising encoder breaks it with SignatureDoesNotMatch 400s
(YASTL#52 hit the same issue). Wrapped in a no-redirect opener so
the .amazonaws.com host allowlist guarantee isn't bypassed by a
302 elsewhere.
- The canonical source_url now includes profile_id so different
plates of the same model get distinct library entries. Older rows
from dev builds keep the model-level URL; the resolve endpoint's
"already imported" check LIKEs both shapes.
UI rebuild:
- Per-plate Save + Save & Slice in Bambu Studio / OrcaSlicer (the
plate is unsliced source, so "Print Now" was misleading and is
replaced by an explicit slicer hand-off).
- Import all plates with sequential progress.
- Folder picker (default: auto-created top-level "MakerWorld"
folder, created on first import, folder tree invalidated so
File Manager shows it immediately).
- Image gallery per plate with keyboard-navigable lightbox.
- Recent imports sidebar (sticky on lg+, vertical list with
jump-to-library / slicer / open-on-makerworld icons).
- Inline follow-up actions on imported plate rows so the user
doesn't scroll back to a top-of-page card.
- Per-plate delete via the standard ConfirmModal (no window.confirm).
- Elapsed-time + phase label during import so the 10-30s synchronous
POST doesn't feel frozen.
- URL-change detection drops the preview when the pasted URL
diverges from the resolved one.
Security hardening (found in review):
- DOMPurify.sanitize on the MakerWorld HTML summary before
dangerouslySetInnerHTML (user-authored content).
- <img> tags in that HTML routed through the thumbnail proxy so
the SPA's img-src 'self' data: blob: CSP isn't widened.
- /makerworld/thumbnail uses follow_redirects=False (the host
allowlist only covers the initial URL).
- 3MF CDN fetch strips the bearer (signed URL is the credential).
- S3 fetch uses a no-op HTTPRedirectHandler for the same reason.
- Upstream filename is os.path.basename'd before persisting.
Tests: 46 backend service unit tests, 19 route tests, 12 frontend
tests — all passing. All user-facing strings localised across the
8 UI languages.
* - frontend/src/App.tsx — removed the 3 stale <AdminRoute> lines (kept the 3 <PermissionRoute> equivalents). TSC + Vite both clean.
- backend/tests/integration/test_auth_api.py — added # pragma: allowlist secret + # noqa: S106 on the test fixture line that GitGuardian flagged.
Three intertwined changes, split by intent:
1. Swap AdminRoute for PermissionRoute on /settings, /groups/new, and
/groups/:id/edit. Admins retain full access; non-admin users whose
group holds settings:read / groups:create / groups:update can now
enter the respective pages instead of being silently redirected to
the dashboard. SettingsPage's individual tabs and cards keep their
existing per-action permission checks, so tabs a delegated user can't
use stay hidden or disabled. AdminRoute had no other callers and is
removed.
2. Fix#1083: editing a custom group's permissions appeared to revert
on reopen. The backend PATCH was persisting correctly — four new
integration tests in test_groups_api.py (including a direct DB read
after PATCH) confirm persistence, empty-list clear, preserve-on-
absent, and 400 on bogus permission. The actual bug was a stale
['group', id] React Query cache: onSuccess invalidated ['groups']
but not the detail key, so the 60s global staleTime served the pre-
update body on re-mount. onSuccess now primes ['group', id] with the
PATCH response body (invalidation is not enough — it races with the
refetch). Frontend regression test added.
3. Delegated users with settings:read but not settings:update no longer
get an infinite loop of failed-save toasts on Settings. The debounced
auto-save effect fires PATCH /settings whenever localSettings diverges
from the server snapshot; without a permission gate this produced an
endless 403 → toast → re-render → effect → 403 loop. Three gates now:
the updateSetting callback short-circuits with a single toast before
localSettings diverges, the effect safety-nets the same check in case
any call site bypasses updateSetting, and the language <select> (the
only direct api.updateSettings bypass in the file) now routes through
updateMutation with the same guard. New settings.toast.noPermissionUpdate
key translated in all 8 locales.
Scoping note: an earlier iteration of change #3 included a
localSettings rollback inside updateMutation.onError — removed in
review because it would have discarded in-progress admin typing on
any transient network/server error. The three up-front guards make
the rollback unnecessary for the permission case (mutation never
fires), and preserving typed-in values on transient failures is the
right call for admins.
The SetupRequest Pydantic schema enforced password complexity unconditionally,
but the route ignores admin_password entirely when an admin user already
exists (the common case for re-enabling auth after it was disabled, or for
LDAP deployments where the local admin is a placeholder). A legitimate
existing password that predated the complexity rule — or the placeholder the
form sends in LDAP mode — hit the Pydantic validator before the route body
could decide it wasn't needed, surfacing as:
422 Value error, Password must contain at least one special character
Move the complexity check out of the schema and into the route body, scoped
to the branch that actually creates a new local admin. Re-enabling auth with
an existing admin now accepts whatever is in the field; first-time setup
still rejects weak passwords with a clear 400 including the specific rule
that was violated.
Regression coverage in test_auth_api.py::TestAuthSetupAPI:
- test_setup_weak_password_rejected_when_creating_new_admin — fresh setup
with "NoSpecial1" → 400, "special character" in detail
- test_setup_reenable_with_existing_admin_ignores_password — seeds an admin,
POSTs /setup with a complexity-failing password → 200, admin_created=false
Two related queue issues surfaced when scheduling an ASAP print with
quantity > 1 on an H2D:
1. Double-dispatch — both items in the batch ended up in 'printing'
status on the same printer, logged as "BUG: Multiple queue items in
'printing' status for printer N". The scheduler seeded its busy
set empty each tick and relied on _is_printer_idle() reading live
MQTT state, but H2D / P1 series lag several seconds between the
print command and IDLE → RUNNING, so the next check_queue() tick
saw IDLE and dispatched the second batch item onto the already-
running printer. check_queue() now seeds busy_printers with every
printer_id that has a row in 'printing' status before iterating,
so any printer with an outstanding dispatched job is excluded
regardless of what MQTT currently reports.
2. Progress bar flashed 100% — immediately after dispatch the queue
item's per-row progress bar showed the prior print's final mc_percent
for a few seconds, then snapped back to 0% when the new print
started ticking. QueuePage.tsx now gates progress / remaining_time /
layer fields on status.state being RUNNING or PAUSE; in any other
state (FINISH from the prior print, IDLE, PREPARE while heating)
the bar renders at 0% with no stale ETA or layer count.
Regression coverage added in test_phantom_print_hardening.py
(TestBusyPrinterSeedingFromPrintingItems, 3 tests): seeding query
returns only printers with 'printing' rows, empty when none exist,
and end-to-end check_queue() does not call _start_print for a pending
item whose printer already has a 'printing' row even when
_is_printer_idle() is forced True.
Reshapes the embedded PrettyGCode viewer (landed in #963) into a focused
archive-preview tool, matching Bambuddy's data model instead of the
OctoPrint-style "connected-printer + library file picker" flow it shipped
with. Reached only from the Archives page 3D-preview button; URL
/gcode-viewer?archive=<id>[&plate=<N>].
Backend:
- /archives/{id}/gcode accepts ?plate=N and resolves the filename by
parsing the suffix as int, so zero-padded names like plate_01.gcode
are found when the plates endpoint reports index 1.
- /archives/{id}/plates gains top-level has_gcode: bool. Source-only
3MFs (PNG/JSON fallback path) surface the flag so the frontend can
skip the picker instead of sending the user into a dead viewer.
- printer_state_to_dict injects name + model into every WS snapshot so
consumers render proper labels on the initial tick without racing a
separate /printers fetch.
- /gcode-viewer (no trailing slash) dropped from the backend so reloads
fall through to the SPA catch-all and keep the layout shell; only
/gcode-viewer/ (trailing slash) and /gcode-viewer/<path> remain for
the iframe + static assets.
Frontend:
- PlatePickerModal shown only for multi-plate archives with sliced
gcode, grid layout with thumbnails matching the Re-print modal.
- Source-only archives show a noGcode toast instead of the empty
viewer.
- ArchivesPage navigate path swapped to /gcode-viewer?archive=<id> with
no trailing slash; GCodeViewerPage iframe forwards
window.location.search so the archive reference survives both the
initial navigate and a full-page reload.
- Viewer iframe's auth path: fetch intercept injects Bearer; a 401
redirects to / so the SPA handles login.
Viewer adapter:
- Stripped the printer selector, WebSocket subscription, library file
picker, tryAutoLoadPrintingFile, BAMBU_BED_SIZES, and updatePrinter-
Selector. The viewer no longer observes live printer state.
- Bed size derived from /archives/{id}/capabilities.build_volume
(extracted from the 3MF's printable_area/printable_height), so H2D,
H-family, and any future printer render on the correct bed without
a hardcoded map.
- loadArchiveById accepts a plate param; fetch intercept rewrites
__bambuddy_archive_<id>[_plate<N>] to /archives/<id>/gcode[?plate=N].
Nav + locale cleanup:
- Sidebar "GCode Viewer" nav entry removed (viewer is archive-scoped
now, not a destination page).
- 32 orphaned gcodeViewer locale keys deleted across all 8 locales.
- platePicker.{title, hint, plateLabel, objectCount, noGcode} keys
added in all 8 locales.
ArchivesPage: the now-unreachable ModelViewerModal render paths + its
showViewer state removed. ModelViewerModal itself stays — File Manager
still uses it for library file previews (plate picker + .3mf 3D model).
pre-commit:
- gcode_viewer/ excluded from trailing-whitespace + end-of-file-fixer
so vendored third-party JS libs don't drift away from upstream.
Incidental sweeps picked up by pre-commit and kept (unrelated but
benign):
- NotificationsPage.tsx: single trailing-whitespace line removed.
- spoolbuddy/scripts/pn5180_diag.py: dead `import gpiod` dropped —
the pn5180 driver module imported at line 27 does its own
`import gpiod` and `gpiod.Chip()` calls, so the diag script's
top-level import was never referenced.
Tests:
- 6 new cases in test_gcode_viewer.py for the backend plate / has_gcode
behaviour (plate=N resolution, zero-padded filenames, missing-plate
404, no-plate fallback, plate=0 rejection, has_gcode true/false).
- 3 new cases in test_printer_manager.py for name/model WS injection.
- PlatePickerModal.test.tsx — 6 frontend cases covering render,
plate-name composition, onSelect payload, backdrop close, and
thumbnail fallback.
* feat: add embedded GCode viewer
Adds PrettyGCode as a built-in GCode visualiser embedded directly in the
Bambuddy layout, so users can preview and inspect GCode files without
leaving the dashboard.
_watchdog_print_start reverted queue items to "pending" at 45 s if
gcode_state hadn't changed, assuming the MQTT project_file was swallowed
by a half-broken session (#887/#967). H2D Pro firmware (01.01.00.00)
routinely keeps state=FINISH for 48-55 s after actually accepting the
command before transitioning to PREPARE. The watchdog reverted items
the printer had already started physically printing; the archive updated
normally via _active_prints, but the queue item was now "pending" again,
and the next scheduler tick after plate-clear re-dispatched the same
item as if it had never run. With one item left in the queue that looked
like a reprint of the just-finished job; with multiple items the
symptom was masked by item N+1 getting dispatched during the race.
Add a second "command landed" signal: subtask_id advancing past the
pre-dispatch value. Bambuddy already mints a unique submission_id per
project_file publish (#1042) and the printer echoes it back on the next
push_status as soon as it starts processing the command - well before
gcode_state transitions on slow-transition models. _start_print now
captures pre_subtask_id alongside pre_state and passes both to the
watchdog, which exits early on either a state change or a subtask_id
advance.
Raise default timeout 45 s → 90 s as belt-and-braces for printers that
neither flip state nor echo subtask_id inside the polling window.
Genuinely half-broken sessions (both signals unchanged across the full
90 s) still revert + force-reconnect exactly as before.
Transient subtask_id=None during reconnect is not mis-detected as a
change. pre_subtask_id=None falls back to state-only checking so the
fix is safe for printers that haven't reported a subtask_id yet.
New test_scheduler_watchdog.py pins the eight behaviours that matter:
pickup via state change; pickup via subtask_id change with state still
FINISH (the exact #1078 case); revert when neither signal changes;
default timeout is 90 s; pre_subtask_id=None state-only fallback;
current subtask_id=None not treated as change; printer disconnect
mid-watchdog leaves DB untouched; item that already moved on is not
clobbered.
PR #939 added the awaiting_plate_clear gate but stored it on
PrinterManager, not on PrinterState. printer_state_to_dict() — which
builds every WebSocket printer_status payload — never emitted the flag,
so the frontend's WS merge preserved the stale false value. The only
path that surfaced true was the 30s HTTP fallback poll, and incoming WS
ticks kept bumping React Query's dataUpdatedAt, pushing the refetch out
further on chatty printers.
Emit awaiting_plate_clear from printer_state_to_dict by reading
printer_manager.is_awaiting_plate_clear(printer_id) directly; returns
False when no id is passed. No frontend change needed — the existing WS
merge carries the flag end-to-end and the button now appears the instant
the printer transitions to FINISH.
Regression tests assert the WS dict always contains the key and surfaces
True when the manager has the flag set for that printer_id.
Affects every printer (A1/H2D/X1C) equally — transport-agnostic path.
Bambu Cloud returns filament_id=null for user presets that only override
fields of a generic base (e.g. "Sting3D ABS" inheriting from
"Generic ABS @BBL H2D"). ConfigureAmsSlotModal fell back to
convertToTrayInfoIdx(base_id), which strips "S" and the version suffix
from "GFSB99_07" to "GFB99" — Generic ABS's filament_id. The printer
accepted and echoed back GFB99, so OrcaSlicer / BambuStudio Sync
Filaments resolved the slot to "Generic ABS" and the custom preset
never appeared on the printer LCD.
The preceding default already set tray_info_idx to the PFUS*/PFSP*
setting_id unchanged, and the rest of the stack round-trips that
format (configure_ams_slot, inventory Assign Spool, and print
scheduler slot-matching on P* short-form IDs). The base_id branch
overwrote the correct default.
Remove the base_id fallback. When cloud detail returns a distinct
filament_id we still prefer it; otherwise the setting_id default
stands. BambuStudio Sync now resolves the custom preset cleanly.
OrcaSlicer falls back to the inherited generic because OrcaSlicer
user-preset JSONs don't carry a filament_id field — that is an
OrcaSlicer limitation and behaviour is strictly not worse than before.
Regression tests (frontend):
- filament_id=null keeps PFUS* as tray_info_idx
- concrete filament_id wins over the default
- GFS* path skips the cloud-detail fetch entirely
- fetch failure degrades gracefully to the PFUS* default
Regression tests (backend):
- test_configure_pfus_preserves_setting_id_pair: HT slot endpoint
forwards both tray_info_idx=PFUS… and setting_id=PFUS… untouched
Thanks to @mrnoisytiger for the browser-console / network / backend-log
data that isolated the fallback path and the OrcaSlicer preset JSON
that showed the missing filament_id field.
A single legacy spool with a 7-char rgba ('FFFFFFF', missing one F)
caused GET /api/v1/inventory/spools to 500 with a pydantic
ResponseValidationError, leaving the reporter with a blank Filaments
page and "Add Spool" silently failing. Root cause spans three layers:
1. Write path: SpoolUpdate.rgba had no pattern constraint (only
SpoolCreate did), so PATCH could plant malformed values in the DB.
2. Frontend: ColorSection hex input's `val.length <= 6 ? 'FF' : ''`
emitted 7-char rgba for 5-char input (XXXXX + FF = 7) and for
7-char typed input (no alpha appended).
3. Read path: SpoolResponse inherited the write-side pattern, so a
single bad row 500'd the entire list endpoint instead of being
tolerated through serialize.
SpoolUpdate.rgba now carries the same ^[0-9A-Fa-f]{8}$ pattern as
SpoolCreate. The hex input emits a fully-formed 8-char RRGGBBAA on
every keystroke — 8-char paste passes through, 7-char drops the
stray, shorter input pads RGB with '0' and appends FF alpha.
SpoolResponse.rgba is now Optional[str] with no pattern — write-side
validation is the right place for format rules; responses must
tolerate historical rows.
Tests: 16 schema tests (SpoolCreate/Update reject, SpoolResponse
tolerate), 7 frontend tests covering every input length 0–8 plus
non-hex strip. A user who already has a bad row in their DB now sees
it render with a default color instead of having to hand-edit SQLite.
The "Print" button on a printer card (and drag-drop-onto-card) used
FileUploadModal to persist the file as a LibraryFile, then dispatched
through POST /library/files/{id}/print. The LibraryFile row + disk file
were left behind after every one-off print, polluting File Manager with
entries the user never asked to save.
FilePrintRequest.cleanup_library_after_dispatch (default False) opts
into post-dispatch cleanup. When set, _run_print_library_file stages
db.delete(lib_file) in the same transaction as archive_print so a
mid-flight FTP / start_print failure rolls both back cleanly, commits
together, then unlinks the library disk file + thumbnail after commit
succeeds. External library files (is_external=True) are never touched.
Only the Printers-page Direct-Print PrintModal sets the flag. Every
other api.printLibraryFile caller (File Manager Print, Project Detail
Print) leaves it unset — their entries are there by user intent.
Also moves formatPrintName out of PrintersPage.tsx into a new
utils/printName.ts module — fa1c46d9 (#881) exported it inline so its
test could import it, tripping react-refresh/only-export-components.
The 0.2.3.1 fix (f03d0c4c) added current_user to the library print
endpoint and plumbed it into the dispatch job object, but the job
runner never read it back out. _run_print_library_file called
ArchiveService.archive_print() without created_by_id and never called
set_current_print_user, so archives created by the printer-card "Print"
button, File Manager prints, and Library prints all landed with
created_by_id=NULL and were invisible to the per-user statistics filter.
The post-print user-targeted notification also had no recipient.
Forward job.requested_by_user_id to archive_print() at creation time
and register the current-print user after start_print succeeds,
matching _run_reprint_archive (lines 686-691).
Reprint-from-Archive still shows NULL for archives whose original
created_by_id was NULL — the reprint path reuses the source row as-is
and only refreshes started_at. Tracked as a separate follow-up.
When two printers were running different plates of the same multi-plate
3MF, the Printers page cards displayed the same file name on both and
there was no way to tell them apart. The Queue view already had this
information by cross-referencing the archive's plate list; the card
didn't have the linkage.
Expose `current_archive_id` (resolved by matching the MQTT `subtask_id`
against `PrintArchive.subtask_id` — the bridge introduced in #972 for
restart-resume) and `current_plate_id` (parsed from `gcode_file` by a
new shared `parse_plate_id` helper) on the status endpoint. The helper
is also called from the WebSocket push path so plate transitions
reflect within 100 ms instead of waiting 30 s for the next REST poll;
the archive id itself stays REST-only since it's stable for the life
of a print and shouldn't make the push path touch the DB.
The card fetches plate metadata via the same `api.getArchivePlates()`
call QueuePage uses — shared React Query cache keeps it cheap across
polls — and renders the actual plate name (or a "Plate N" fallback)
only when `is_multi_plate` is true. Single-plate prints stay clean.
Falls back to the previous `plate_N.gcode` regex path when there's no
archive linkage (e.g. prints started directly from the printer LCD).
Tests cover the plate-id extraction across Bambu Studio path shapes
(backend parse_plate_id, printer_state_to_dict wiring) and the label
override precedence in formatPrintName (frontend).
The strict CSP shipped in 0.2.3b4 / 0.2.3.1 whitelisted only `https:`
for `frame-src`, so the Filament tab's Spoolman iframe was blocked
on the typical self-host setup where Spoolman runs on plain HTTP on
a LAN. Reporter saw a blank Filament page with a brief Spoolman
flash on reload and a browser-console CSP violation pointing at
`http://<host>:7912/spool`.
Allow `http:` as well, matching the `connect-src 'self' ws: wss:`
pattern already used for WebSockets. `frame-ancestors 'none'` still
prevents Bambuddy itself from being framed cross-origin, which is
the protection that actually matters for clickjacking defense.
After configuring an AMS-HT slot with a custom cloud preset, the slot
card and Configure modal kept showing "Generic PLA" even though the
printer and slicer had the correct preset. The `/slot-presets` response
keyed HT entries at `ams_id * 4 + tray_id = 512`, but frontend lookups
used `ams_id` directly (128 on PrintersPage via getGlobalTrayId, 64 on
SpoolBuddy via a one-off formula). All three agreed for regular AMS, so
the mismatch only surfaced on HT — the saved preset never reached the
UI and the render fell through to `tray.tray_type`.
Backend now keys via a helper that mirrors frontend `getGlobalTrayId`.
SpoolBuddy's AMS page switches to the shared helper. Regression test
covers regular, HT, and external slot keys.
Critical safety fix. The bed-jog dialog's "Home Z" button sent a bare
`G28 Z` over gcode_line. On Bambu printers where the Z endstop is at
the top (bed moves UP into it — H2C, H2D, H2S, X1 family), `G28 Z`
skips the toolhead-park step that a full `G28` runs first, so the bed
rises at full speed with nothing getting out of the way. The reporter
only escaped damage because the toolhead happened to be parked on the
purge chute.
The /printers/{id}/home-axes endpoint and BambuClient.home_axes() now
always send bare `G28` regardless of the axes argument, triggering the
firmware's safe multi-step routine (park toolhead → home XY → home Z).
The axes argument is kept for API compat but ignored; invalid values
still return 400.
Frontend retitles the button "Auto Home" and updates the dialog copy
in all 7 locales so users aren't surprised when X/Y motion happens
before Z. Parameterized regression test asserts z/xy/all all produce
bare G28.
Users integrating a Shelly plug through an external MQTT broker
(ioBroker, Zigbee2MQTT, HA's MQTT broker, etc.) lost the plug's
power/state/energy readings after every Bambuddy restart. The only
fix was opening Settings → Smart Plugs, renaming the topic to a dummy
value, saving, renaming back, and saving again.
Root cause: three code paths configure an MQTT smart plug's
subscriptions — the startup restore in main.py, the create route,
and the update route — and they had drifted. The create/update
routes used the newer per-type model (mqtt_power_topic /
mqtt_energy_topic / mqtt_state_topic with per-type paths,
multipliers and mqtt_state_on_value) while the startup restore was
still on the legacy single-topic model. Worse, the restore loop
short-circuited on `if plug.mqtt_topic:`, skipping any plug whose
topics were only set in the new per-type fields — exactly the shape
of a Shelly-via-ioBroker config, which publishes power and state on
separate topics. The "rename, save, rename back" workaround routed
through the update endpoint and re-established the subscription the
correct way.
Extracted the topic-resolution + service.subscribe() call into
subscribe_plug_to_mqtt() in mqtt_smart_plug.py and routed all three
paths through it so the schema can't drift again. The helper keeps
the legacy `mqtt_topic` field working as a fallback for all three
data types — matching the behaviour the startup restore used to
have via subscribe()'s internal `effective_*_topic or topic`
collapsing, and matching the change-detection dict already used
during updates.
Regression tests cover: per-type topics restored without a legacy
topic, legacy single-topic backward compat, per-type multipliers
overriding legacy, per-type winning when both are set, the
empty-config skip case, and topic-list de-duplication.
On bare-metal Raspberry Pi OS bookworm / armv7l / Python 3.11, 3MF
files larger than a few megabytes arrived complete via the
virtual-printer FTP server but the copy into data/archives/ was
silently truncated. The archive row was still written, the printer
card looked fine, and the problem only surfaced later when opening
the archive — the subsequent zipfile.ZipFile() in
GET /archives/{id}/plates raised BadZipFile and the UI came up blank
with no thumbnail, plate list, or filament data.
Two things conspired:
1. archive_print() used shutil.copy2, which takes Python's sendfile()
fast path on Linux. On the reporter's kernel/fs combination
sendfile returned a short count on the first call for the upload
sizes hit in practice and the destination ended up truncated.
Small files completed in one syscall and were fine.
2. ThreeMFParser.parse() caught the resulting BadZipFile in a bare
`except Exception: pass`, so the archive pipeline kept going with
empty metadata and left the bad file on disk — nothing in the
logs hinted anything had gone wrong until a support bundle came
in and the "Failed to parse plates" warning fired much later.
The archive copy is now an explicit chunked read/write with fsync —
sendfile is not in the path. After the copy, if the source was a
valid ZIP but the destination isn't, we refuse to create the archive
row, remove only the truncated file (and the archive directory if
empty — archive_dir is created with exist_ok=True so rmtree would be
unsafe if a same-second same-filename collision happened), and log
both sizes at ERROR so the condition is obvious in future support
bundles. The parser's silent catch now logs at WARNING for the same
reason.
All nine archive_print() call sites already check `if archive:` or
`if not archive:`, so returning None for corrupted ZIPs propagates
cleanly without behaviour changes elsewhere.
Regression tests cover single-chunk and multi-chunk copies, mtime
preservation via copystat, overwrite of an existing destination, a
ZIP roundtrip through a multi-megabyte 3MF, the new parser WARNING,
and a truncation sentinel verifying that zipfile.is_zipfile() flips
to False on a half-written ZIP — the exact post-condition
archive_print now trusts.