navPermissions in Layout.tsx gated three resources on the LEGACY *:read flag.
Default Operators group is seeded with *_own only (and the migration map flips
legacy → _own on existing groups), so non-admin users never held the legacy
permission and the sidebar hid Archives / Queue / Files even though the
underlying API accepted their requests. Reporter only spotted Files; same bug
shape applied to Archives and Queue.
Fix: navPermissions accepts Permission | Permission[]; the three affected
resources list all three tiers. isHidden checks .some(hasPermission) for
arrays. Permission type extended with the matching *_own / *_all variants —
backend already shipped them, the TS type just didn't declare them.
The provider toggle, schema, template, and NotificationService.on_printer_offline
all shipped, but no caller invoked the dispatcher — the offline event was an
orphan toggle. Edge detection in on_printer_status_change now schedules a
debounced (60s) background task on the connected→disconnected transition;
reconnect before the window elapses cancels it. Covers both upstream paths
(smart-plug power-off via mark_printer_offline, and MQTT staleness via
check_staleness), both of which already route through the status callback.
The "back online" channel is the existing print-failure notification on
firmware FAILED report — no symmetric on_printer_online needed.
ProtectedRoute and PermissionRoute now pass the requested location as
router state when redirecting to /login. LoginPage stashes it in
sessionStorage before the OIDC provider redirect (since window.location
kills React state) and consumes it on all three post-login navigations
(credentials, 2FA, OIDC token exchange). Targets are sanitized to
same-origin internal paths only — protocol-relative and /login itself
are rejected to prevent open-redirect.
QR labels (https://host/inventory?spool=N) now land on the scanned
spool instead of the printer page after authentik / any OIDC SSO login.
Older print_archives rows (and rows that landed via the SQLite ↔ Postgres
cross-DB restore path) can have created_at = NULL because the column was
originally created without a DEFAULT clause — server_default=func.now()
only fires at table creation, not for existing rows or raw cross-DB
inserts. The list_archives response model required a datetime, so a
single NULL row 500'd the whole endpoint via Pydantic ResponseValidationError.
- Boot-time backfill: COALESCE(completed_at, started_at, now()) for
any row where created_at IS NULL. Dialect-branched (SQLite datetime('now')
vs Postgres NOW()).
- Schema: created_at is now Optional on ArchiveDuplicate, ArchiveResponse,
and ArchiveSlim so a future NULL-leaking path doesn't break the list
endpoint again.
filterKnownHMSErrors and the modal-local copy gated visibility on
ERROR_DESCRIPTIONS membership. H2C 0500_809C carries IGNORE_RESUME /
PROBLEM_SOLVED_RESUME but is missing from the bundled 853-entry catalog,
so the entire error — pip, count, panel, action buttons — never rendered
even though backend captured + dispatched it correctly.
The gate isn't dead code: PrintersPageBucketing pins the post-cancel
0C00_001B junk-echo regression to it. Widen the predicate to keep
(cataloged) OR (actions.length > 0) so noise is still filtered out
while user-actionable faults always surface.
Replace the modal's inline filter with the shared helper so badge
counts and modal contents agree by construction. Fall back to
hmsErrors.unknownCode ("Unknown HMS code — see the Bambu Lab wiki
for details.") when the catalog has no entry. New key translated in
all 11 locales.
New bucketing test pins PAUSE + uncataloged-with-actions = error;
existing FAILED + uncataloged-without-actions = finished stays green.
P1S and other Bambu printers tick layer_num during the pre-print calibration
sequence (homing -> auto bed leveling -> bed-surface scan -> nozzle clean ->
purge / wipe), so a bare `2 <= layer_num <= 5` gate fires the first-layer
notification minutes before the first real extrusion. The attached photo
shows a lowered bed, parked toolhead, and a clean plate -- exactly the
state during PREPARE, not after layer 1.
The reporter's log timeline made it explicit:
- 13:54:27 PRINT START detected
- 14:10:13 [SNAPSHOT] Capturing fresh frame (notification fires here)
- 14:44:28 gcode_state: RUNNING (debug log, only visible because they
enabled debug logging mid-print)
So the notification went out ~30 minutes before the print actually started.
Fix in main.py:6043 -- the on_layer_change first-layer block now requires
both:
- state.state == "RUNNING" (gcode_state is RUNNING, not PREPARE)
- state.mc_print_sub_stage in (None, 0)
(0 = "Printing" in the canonical STAGE_NAMES at bambu_mqtt.py:376;
None preserved as a no-opinion fall-through for any firmware that
doesn't push the sub-stage so unknown-firmware installs keep
their existing behaviour)
_first_layer_notified is only set after the gate passes, so calibration
ticks are non-consuming -- the next on_layer_change edge fires the
notification once the printer is actually printing.
The trigger window widens from [2, 5] to [2, 10] so that if calibration
consumed several layer_num slots before RUNNING, the deferred edge
still falls inside. The RUNNING + sub-stage gate ensures we don't fire
on a stale layer count.
Administrators system group sync
- Fresh installs already bootstrap with ALL_PERMISSIONS, so they always have
every permission. Upgrades previously only got what one-off backfill blocks
in seed_default_groups() explicitly listed (library:purge, archives:purge,
the OWN/ALL read-flag block, orca_cloud:auth, pipelines:*). Any Permission
enum member added without a matching block silently stayed missing on
existing admin rows. The most recent gap was printer_sensor_history:read
(Sensor History charts returned 403 for upgraded admins).
- seed_default_groups() now syncs Administrators to ALL_PERMISSIONS on every
startup: append every Permission value that isn't already on the row.
Additive only -- hand-added custom permissions are preserved.
- The pure-admin one-off backfills (library:purge / archives:purge block,
the OWN/ALL + orca_cloud:auth + legacy-read-flag block, the Administrators
branch of the pipeline backfill) are retired since the sync subsumes
them. Non-admin backfills (Operators / Viewers OWN-tier reads, Operators
orca_cloud:auth, pipelines for non-admin groups, makerworld:*, clear_plate
cross-group adders) are untouched.
- Tests: test_administrators_printer_sensor_history_read_backfilled
(regression for the reported gap),
test_administrators_sync_covers_every_current_permission (generic
invariant -- any future new permission lands on admin without needing
a one-off test), test_administrators_sync_is_additive_only (custom
permissions preserved). 12/12 backfill-migration + 102/102 broader
permission tests green; ruff clean.
Pipelines runs dashboard
- PipelineRunsPage.tsx: the Pipeline / Status / Target filter row's three
native <select> elements are replaced with a bambu-themed FilterDropdown
(button trigger, floating menu, optgroup-style headers for the Target
picker, hover + selected states with a check mark, closes on outside
click and Escape). Same value/onChange contract -- visual only.
- SlicerPipelinesPanel.tsx: wrap list?.pipelines ?? [] in useMemo so the
reference is stable when the data is stable. Fixes the
react-hooks/exhaustive-deps warning where the inline fallback returned
a fresh empty array every render, invalidating both downstream useMemo
caches (target-options + filtered-pipelines list).
PR A/B turned the slice modal's preset bundle into a one-click dispatch
with a pinned target printer. PR C closes the original issue: operators
type in a number of copies, Bambuddy slices once and distributes prints
across a fleet per the pipeline's chosen fanout strategy. A new dashboard
surfaces every run with filters, expandable per-copy status, cancel,
and retry-failed-copies. WS pushes keep everything live.
Backend
- copies field on POST /run, capped by new pipeline_max_copies setting
(default 50, hard cap 1000). PipelineRun.parent_run_id chains retries.
- SlicerPipelineUpdate accepts target_kind (specific_printer /
printer_class), target_model_class, fanout_strategy.
- Eligibility matcher branches: class-targeting enumerates matching
Printer rows, runs per-printer checks via a status_lookup closure,
returns printer_reports[]. New issue kinds: no_class_matches,
class_not_set.
- _pick_assignments distributes copies per strategy:
- max_parallel: target_model set, printer_id None — scheduler picks
- round_robin: copy i → eligible[i % N], fixed printer_id
- fill_one_first: all copies pinned to eligible[0]
All three reuse the slice-once path through slice_dispatch.enqueue.
- New routes:
- GET /pipeline-runs (paginated, filterable by pipeline + status)
- POST /pipeline-runs/{id}/retry-failed (creates child run with
copies = failed+cancelled count, parent_run_id set)
- Cancel cascades to all N queue entries (only pending/queued)
- _roll_up_run_status computes run-level status from per-job statuses;
introduces partial_failure for "some completed, some failed".
- ws_manager.broadcast_to_user emits pipeline_run_updated on every
state transition with the full materialised response.
Frontend
- Pipeline editor: target_kind radio + class picker (filtered to
installed models) + fanout-strategy radio. Read-only row shows
"X1C · Round robin" for class pipelines.
- RunWithPipelineModal: copies number input bounded by
settings.pipeline_max_copies. Accepts class-targeted pipelines.
- Settings → Workflow → Queue & Dispatch: new "Slicer Pipeline limits"
card with the max-copies input.
- New /pipelines/runs dashboard page (sidebar entry, gated on
pipelines:read). Two-filter dropdown, 25-per-page pagination, per-row
expandable to job list, Cancel + Retry-failed buttons.
- useWebSocket case for pipeline_run_updated invalidates both
pipeline-runs-all and pipeline-runs/{id} query keys.
Two real gaps from the PR B drop:
1. Run-with-pipeline only existed in the file manager. Operators who keep
working files in archives had to copy them to the library to use a
pipeline.
2. Triggering a slice via a pipeline produced a silent multi-second-to-
minute wait. The manual SliceModal flow shows the sticky
"Slicing X - Generating G-code 75%" persistent toast; the pipeline
path went through asyncio.create_task directly and never registered
with SliceJobTracker.
Archive entry point
- POST /slicer-pipelines/{id}/check-eligibility and /run accept
source_archive_id as an alternative to source_library_file_id (XOR,
enforced by Pydantic validator).
- PipelineRun.source_archive_id is a new nullable FK column with the
ALTER TABLE migration in run_migrations (idempotent via _safe_execute,
works on SQLite + Postgres).
- _resolve_source branches: archive path reads source_3mf_path with
fallback to file_path, mirroring routes/archives.py.
- ArchiveCard's context menu picks up a "Run with pipeline" item next to
Slice (only on source archives), gated on useSlicerApi + pipelines:run.
Slice (only on source archives), gated on useSlicerApi + pipelines:run.
- Path-safety: SEC-PATH-OK markers added at both LibraryFile.file_path
and archive.source_3mf_path join sites, citing the upload-time
validators.
Progress toast
- Pipeline orchestration is now the `run` callable of a
slice_dispatch.enqueue call — the same dispatcher SliceModal uses —
instead of a bare asyncio.create_task. The SliceJob lifecycle drives
the existing progress toast end to end with no separate notification
surface for pipeline runs.
- PipelineRun.slice_job_id is set before the 202 returns.
- RunWithPipelineModal calls useSliceJobTracker().trackJob() from
runMutation.onSuccess.
- RunWithPipelineModal source prop is now {kind, id, filename}
mirroring SliceModal.SliceSource; api.checkPipelineEligibility +
api.runPipeline take a discriminated-union source argument.
The SliceModal forces the user to pick four slots every time (printer /
process / filament(s) / bed type). For fleet production that's tedious
and error-prone. Pipelines let an operator save a named bundle and apply
it with one click on the next file.
PR A is bundle-and-management only. PR B adds single-target dispatch,
PR C adds multi-copy batch with capability-matched fanout. Future-PR
columns (target_kind / target_printer_id / target_model_class /
fanout_strategy) ship in this migration so PR B+ is code-only, not a
schema bump.
Backend
- New model SlicerPipeline + slicer_pipelines table; soft-delete via
is_deleted so PR B+ run history can still resolve metadata.
- Pydantic schemas reuse the existing PresetRef shape from
schemas/slicer.py.
- CRUD routes at /api/v1/slicer-pipelines/ — list (newest first by id
DESC), create (201), get-by-id, partial PUT, soft-delete (204).
- Three new permissions: PIPELINES_READ / PIPELINES_WRITE / PIPELINES_RUN.
Administrators + Operators get all three; Viewers get READ.
Backfill in seed_default_groups() so existing installs upgrade
cleanly. All three denied to API keys for now.
Frontend
- Settings → Workflow splits into two horizontal sub-tabs mirroring
the Authentication tab pattern: "Queue & Dispatch" (existing
Workflow content) and "Pipelines" (new). URL deep-link via
?tab=queue&sub=pipelines.
- SlicerPipelinesPanel — list, inline rename, delete, stale-preset
warning when a referenced preset no longer resolves.
- SliceModal gets "Apply pipeline ▾" + "Save as pipeline". Apply
fills all four slot states; the filament list right-pads from
current state so a pipeline with fewer entries than the current
source's slot count keeps the existing tail.
FTP push to the printer into the server-side scheduler tick. That
removed the browser-side upload the old XHR-progress modal listened
to — users only saw the queue item flip to "active" with no visibility
into the FTP push + the H2D/H2D Pro 80-210 s project_file digestion
window before the printer actually started.
Port the legacy bg-dispatch toast rendering from
0b43ac0d:frontend/src/contexts/ToastContext.tsx lines 510-650 back in
place verbatim — same DOM tree, same Tailwind classes, same
formatFileSize bytes line, same uppercase status chip, same collapse
chevron, same awaitingPrinter derivation, same auto-dismiss. The only
adapt is the event ingestion: a useEffect maps the four scheduler-side
WS events to the legacy DispatchToastJob shape.
The toast materializes when the FTP push to the printer ACTUALLY
STARTS (queue_item_uploading) — NOT on POST /queue. A draft that
fired at queue-add made the toast jump to "Dispatched" before any
upload had happened.
Four backend WS events drive it: uploading (carries printer_name +
total_bytes), upload_progress (throttled at 200 ms / 256 KB to match
legacy background_dispatch.py:614-615 1:1, first call always emits,
completion always emits; an _UploadProgressBridge bridges from the
FTP executor thread to the asyncio loop), acked (printer transitioned
out of pre_state), failed (with a reason key the toast looks up as
dispatchToast.failed.{reason}). No queue_item_dispatched event: the
legacy path kept status=processing from upload start until printer
ack, "Awaiting printer..." derives from upload_progress_pct >= 99.9
(legacy uploadDoneAwaitingPrinter trick).
Per-user routing: WS connect resolves the principal username to
User.id once and stashes it on websocket.state, so
ws_manager.broadcast_to_user filters O(connections). Auth-disabled
installs route user_id=None to all connections — matches the legacy
single-user behaviour. The watchdog receives created_by_id through a
new kwarg so the static method can still emit acked without
re-fetching the queue item.
extract_nozzle_mapping_from_3mf has a single-active-extruder shortcut
(added in #851 for #827) at threemf_tools.py:354 that runs before the
per-filament group_id mapping. It fires whenever
extruder_nozzle_stats reports exactly one extruder as active. On
multi-nozzle Bambu printers (H2D / H2D Pro / X2D / H2C) the slicer
under-reports the second extruder when its nozzle volume-type isn't
enumerated in the slice's profile (common with HT-AMS / High-Flow
asymmetric setups, e.g. HT-AMS feeding the right nozzle on an H2D):
['Standard#1', 'Standard#0'] even when both extruders are genuinely
used. sum(active_extruders) == 1 → every filament was force-assigned
to physical_extruder_map[active_idx], the authoritative group_id was
discarded, and the Filament Mapping panel showed both filaments
badged L with the auto-match hard filter blocking the wrong-nozzle
tray as "Type not found".
Bug is parser-side and model-agnostic, not gated on AMS hardware —
typical dual-AMS H2D slices contain ['Standard#1', 'Standard#1']
(sum==2), never enter the shortcut, and work fine. Physical extrude
routing was not affected (gcode + project_file nozzle_mapping path
from #1780 is authoritative). User-visible harm: wrong L/R badge and
no auto-match for the second nozzle.
Gate the shortcut on len(distinct_group_ids) <= 1 from
slice_info.config. The slice_info parse is hoisted above the shortcut
and reused by Priority 1, so the gate adds zero extra I/O. The gate
only narrows the shortcut — it can't widen the bug onto any
previously-working slice. Generalizes to H2C and any N-nozzle printer
for free (no per-printer branching).
The original #793 fix added the spool note as an HTML title= tooltip
on each picker button in AssignSpoolModal.tsx. title= only surfaces
on hover, which doesn't exist on touch devices — phone users tapping
a card just selected it, the note never appeared. Users who store
their tracking ID in the note field were blind on mobile (raised by
@EmcetPL on the closed issue).
Render the note as a small muted truncated line directly under the
weight on both the internal-inventory branch (line 436-ish) and the
Spoolman branch (line 510-ish): text-[10px] text-bambu-gray/70 mt-1
truncate, kept inside the truthy `&&` guard so empty notes don't add
a blank row. The existing title={spool.note} is preserved on the new
<p> so desktop hover and mobile long-press still surface the full
untruncated text for notes that overflow the truncate.
Mirrored across both inventory branches per the parity rule
(internal and Spoolman pickers stay shape-equal). No backend change,
no new state, no popover, no new touch target.
require_ownership_permission gates API keys on `all_perm` only — the
comment at auth.py:1659 says OWN and ALL "both map to the same scope
flag" for queue / archives / etc., so checking `all_perm` is the
correct gate. Library deliberately broke that: LIBRARY_UPDATE_OWN /
LIBRARY_DELETE_OWN mapped to can_manage_library, but the ALL variants
were in _APIKEY_DENIED_PERMISSIONS. Result — every API-key request to
DELETE /library/files/{id}, PUT /library/files/{id} (rename), or
POST /library/files/move hit "administrative operations" 403, even
for keys with can_manage_library=True. Only slice worked, because it
doesn't go through require_ownership_permission.
The "ALL stays admin-only because it crosses the user boundary"
intent was internally inconsistent. API keys have no per-row
ownership identity (user=None), so the route's
`file.created_by_id != user.id` ownership check would AttributeError
on a key acting under OWN anyway — the only working path is
can_modify_all=True, which `all_perm` denial blocked outright.
Fix folds LIBRARY_UPDATE_ALL and LIBRARY_DELETE_ALL into
_APIKEY_SCOPE_BY_PERMISSION under can_manage_library, matching the
can_queue precedent (QUEUE_UPDATE_OWN and QUEUE_UPDATE_ALL both
map to can_queue for the same per-key-identity reason). Both removed
from _APIKEY_DENIED_PERMISSIONS. LIBRARY_PURGE stays denied — it
bypasses the soft-delete window and is genuinely destructive.
Three distinct bugs combined into one user-facing failure: clicking
Stop / Problem-solved-and-resume / Ignore-and-resume returned 200 OK
but the printer didn't act, modal stayed up, print stayed paused.
Verified by injecting candidate command shapes on device/<sn>/request
against a live H2D paused on a wrong-plate HMS (print_error=0x05008051).
(1) hms_resume / hms_stop dispatched the "err"-bearing shape that
BambuStudio doesn't actually send; Bambu firmware silently rejects it.
Both now send the plain shape ({"print":{"command":"<x>","param":"",
"sequence_id":"0"}}). PAUSE -> FAILED in 1.7s for stop, PAUSE -> RUNNING
in <2s for resume.
(2) IGNORE_RESUME mapped to idle_ignore, which is BambuStudio's
"dismiss a warning" command and only works for non-pause warnings.
hms_ignore now branches on state.state == "PAUSE": paused -> plain
resume; not-paused -> idle_ignore with the full-length err.
(3) 64-bit hms[]-array faults were truncated to a non-matching err.
short_code in _parse_status discarded 32 of the 64 identifier bits, so
the firmware didn't match it to the active fault. HMSError.full_code
now carries the canonical hex identifier (16 chars for hms[] faults,
8 chars for print_error faults). Catalog lookup tries 16-char first,
falls back to 8-char. HmsActionBody.print_error pattern relaxed to
^[0-9A-Fa-f]{8}([0-9A-Fa-f]{8})?$.
(4) execute_hms_action returned publish-success as success, masking
every silent-rejection bug above as 200 OK. Route now snapshots
(state.state, len(state.hms_errors)) before dispatch, awaits
HMS_ACTION_ACK_WAIT_SECONDS (default 2.5s, module-level so tests
override), and returns 502 with "Printer did not acknowledge HMS
action within 2.5s" if state didn't move.
The toast was calibrated for power users — lowest bars were 100 prints,
50 archives, 100 cost. Most installs never crossed any of them, especially
with the install base ~doubling since March. Matomo confirms: only 4
prints-100 and 3 archives-50 deeplink visits to /sponsors.html in a 7-day
window despite tens of thousands of weekly pulls.
Adds lower thresholds without changing priority order or cooldown:
PRINT_MILESTONES = (10, 25, ...)
ARCHIVE_MILESTONES = (5, 10, ...)
COST_MILESTONES = (25, 50, ...)
Existing toast copy uses {count}/{total} interpolation in all 11 locales,
so no i18n changes. Tests rebalanced so "below the floor" still tests
with the new floor; new test_fires_at_lowest_threshold pins prints-10.
On dual-nozzle printers (H2C/H2D), the External card stacked a
separate "Ext-L" / "Ext-R" caption below each tray to mark which
extruder it fed. That caption appeared on the External card only,
making the bottom row of the printer card's AMS panel visibly
taller than the row above it.
Fix: the L/R distinction now lives inside the slot's colour circle
in place of the numeric index, and the bottom caption is removed.
FilamentSlotCircle's slotNumber prop is widened to `number | string`
to carry the letter. Single-nozzle externals (one tray, no L/R
distinction) keep the numeric "1".
The Ext-L / Ext-R strings still drive the slot's "location" label
in the filament hover card, so detail context is preserved.
streams when one viewer closes
1) Offline tiles now show OFF (not LIVE)
CameraWall.modeByPrinter assigned 'live' to any visible printer
without considering status.connected, so a disconnected X1C wasted
a live-budget slot AND rendered the red LIVE chip on top of the
WifiOff placeholder. Disconnected printers now map to 'paused' and
don't decrement liveBudget — the existing WifiOff + Off chip
rendering takes over.
2) /camera/stop no longer kills other viewers' streams
The cam-wall tile, EmbeddedCameraViewer, and the /camera/:id popup
all subscribe to the same fan-out broadcaster for a printer.
/camera/stop used to unconditionally shutdown_broadcaster() + kill
every ffmpeg process for the printer, so closing the embedded viewer
while the cam-wall tile of the same printer was live force-killed
the source the tile was pulling from — the tile's <img> errored.
New get_subscriber_count(key) accessor in camera_fanout.py exposes
the broadcaster's subscriber list length. /camera/stop now reads
that first; when >= 1 subscriber is still attached, return
{stopped: 0, skipped: true} and leave the broadcaster + ffmpeg
processes alone. The leaving viewer's HTTP teardown still runs the
natural iter_subscriber.finally -> unsubscribe path, so its slot is
released; the broadcaster keeps serving the other viewers. Single-
viewer close still hits the immediate force-teardown (count is 0).
Three issues from the post-merge audit of the unified-dispatch PR, all
pre-existed on dev but became more impactful once every print routes
through the queue:
1. Start/Stop ownership gates. /queue/{id}/stop required QUEUE_UPDATE_ALL
(admin-only) -- operators saw the Stop button in the queue UI but got
403 on click. /queue/{id}/start required QUEUE_UPDATE_OWN with no
ownership check -- _OWN holders could start anyone's queue items via
direct API. Both routes now use require_ownership_permission, mirroring
/cancel. Stop is strict (rejects unowned items for _OWN); start preserves
#1670's VP-import flow where _OWN can start NULL-owner items and claim
ownership at click-time. Frontend QueuePage Start/Stop buttons flip
from printers:control to canModify('queue', 'update', created_by_id).
2. TOCTOU race on insert_position. Concurrent ASAP inserts to the same
scope both computed MAX(position) from before the other committed; in
an empty scope, both inserted at position=1 (duplicate). Wraps the
read+update in a transaction-scoped Postgres pg_advisory_xact_lock
keyed on the printer_id. Different printers don't contend. SQLite
serializes writes implicitly so the path is no-op there. Dialect is
checked against the live session binding, not the is_sqlite() helper,
because the test fixture overrides get_db to SQLite while
settings.database_url still points at Postgres.
3. /reorder duplicate-position validator. POST /queue/reorder set position
from the payload in a loop with no uniqueness validation -- a buggy
drag-drop client could leave the queue with ambiguous ordering (the
scheduler's ORDER BY (printer_id, position) ties break by row order).
New model_validator on PrintQueueReorder rejects duplicates at the
schema layer with 422 + "Duplicate positions in reorder request: [N, ...]".
pip-audit flagged two advisories at the resolved versions in the venv.
Neither is reachable in shipped Bambuddy, but the pins are taken so
the audit stays clean and a future reachable advisory in either
package isn't masked by existing noise.
pydantic-settings 2.14.2 patches GHSA-4xgf-cpjx-pc3j —
NestedSecretsSettingsSource with secrets_nested_subdir=True followed
symlinks pointing outside the configured secrets_dir, reading
out-of-tree files into settings values and bypassing the documented
secrets_dir_max_size cap. Affected: >=2.12.0, <2.14.2. Bambuddy uses
pydantic-settings only for env-var-backed config; the secrets-dir
loader is not used (grep clean on NestedSecretsSettingsSource /
secrets_nested_subdir / secrets_dir under backend/).
msgpack 1.2.1 patches GHSA-6v7p-g79w-8964 — reusing an Unpacker
instance after it caught an error can crash with SEGV, which is a
DoS vector on untrusted input. msgpack is not a runtime dep of
Bambuddy; it enters the tree only as a transitive of CacheControl,
itself pulled by pip-audit (the very tool that surfaced the
advisory). Pin placed in requirements-dev.txt next to pip-audit so
it travels with the security-scan tooling rather than implying a
runtime use.
The /system/appliance endpoint is fetched by the SPA's i18n bootstrap on
mount to seed locale, hostname, timezone, and the chrony NTP-gate state
BEFORE any login state exists. The route handler itself has no auth
dependency and the test_route_auth_coverage allowlist correctly marks it
public, but the global auth_middleware in main.py — which short-circuits
every /api/ path not in PUBLIC_API_ROUTES — was never told about it.
Result: every browser session on an auth-enabled install logged a 401
on the appliance endpoint before login.
Added /api/v1/system/appliance to PUBLIC_API_ROUTES with a comment
pointing at the dual-list pattern so this doesn't drift again, and a
regression test in TestAuthMiddlewarePublicRoutes that posts /auth/setup
to turn auth on, then asserts the endpoint returns 200 with the
documented shape (hostname / timezone / locale / time_synced fields all
present).
Adds a global local_login_enabled setting plus a per-provider
is_autologin flag on OIDCProvider so operators who run their own SSO
enabled, or if the calling admin has no UserOIDCLink — either would
lock everyone out. App-layer invariant: at most one provider can carry
is_autologin; setting it on one clears it on every other.
/auth/advanced-auth/status surfaces both new fields so the LoginPage
decides UI in one query. The env-var bypass flips the reported
local_login_enabled back to true so the SPA matches what the route
will accept.
New view toggle on the Printers page renders a responsive grid of live
camera tiles instead of printer cards. Reuses the existing /camera/stream
fan-out so the backend ffmpeg pipeline is unchanged.
To stay sustainable on the median Pi 4 install, only on-screen tiles run
live, and only up to a per-user cap (default 4). Other visible tiles
fall back to periodic /camera/snapshot polling (default 8s). Off-screen
tiles pause entirely. Tiles POST /camera/stop on unmount and on
leave-live so the backend transcoder slot is released the same way
EmbeddedCameraViewer does it.
CameraTile is a 3-mode leaf (live / snapshot / paused) with a single
<img> and an onError no-signal fallback. CameraWall is the scheduler:
IntersectionObserver tracks visibility, a stable walker over the sorted
printer list assigns live slots first-N-visible to avoid LRU churn. Same
['printerStatus', id] React Query cache the cards already populate, so
flipping between Cards and Cam Wall is instant.
Tile click honours the existing Settings camera_view_mode preference
(window vs embedded). Both wall settings are per-user localStorage
(camWallMaxLive, camWallSnapshotSec) — a Pi 4 user and a NUC user want
different caps.
Bambu's per-tick AMS push carries only the dry_time countdown — the
filament name and target temperature the user chose are never echoed on
the wire. The AMS card had no source of truth for them and rendered the
bare "Drying · 11h 35m left". The badge now shows
"Drying · PETG @ 65°C · 11h 35m left", matching the cycle the user
actually started.
BambuMQTTClient caches {ams_id: {filament, temp}} on send_drying_command
(mode=1), clears on mode=0 and on the dry_time falling edge to 0 — the
same per-AMS edge detector that drives the smart-plug-after-drying
callback. PrinterManager.get_drying_targets exposes it, the four
printer_state_to_dict call sites thread it through, AMS schema gains
dry_target_temp + dry_filament, and routes/printers.py builds the same
fields into the manually-constructed AMSUnit response.
When no cached target exists (drying started in a previous backend
lifetime, or initiated outside Bambuddy), the badge falls back to the
first loaded tray's tray_type + RFID-recommended drying_temp — the
heuristic the popover already uses to seed defaults.
i18n: printers.drying.targetSummary = "{{filament}} @ {{temp}}°C" in
all 11 locales. Parity check 5356 leaves per locale.
Note: a user reported the H2D's own physical display still labels the
cycle by the loaded tray's filament (e.g. "PLA" instead of the
Bambuddy-requested "PETG"). The wire payload is correct end-to-end —
journalctl shows filament: "PETG" sent and result: success ACKed — and
the badge in Bambuddy's own UI now reflects what we actually sent,
independent of the firmware's display choice.
Continue Auto-Drying while a print is running on capable hardware.
New Settings > Print Queue > "Continue drying while printing" toggle
(default OFF). Extends _check_auto_drying in print_scheduler.py to
evaluate running printers when supports_drying_while_printing(model,
firmware) returns true. Strict allowlist verified per Bambu wiki
release notes for "Print While Drying" / "printing while filament is
drying": H2D 01.03.00.00+, H2C/H2S/P2S/H2D Pro 01.02.00.00+, X2D/A2L
01.01.00.00+, X1C 01.11.02.00+. P1*, A1, A1 Mini, X1 (non-C), X1E
intentionally excluded. Mid-print drying temperature is capped at
max(40, preset_temp - 5) to protect spools from heat damage inside the
hot enclosure during a print, matching Bambu's own "lower drying
temperature during printing" guidance.
Rotate-spool toggle in the drying popover is now disabled when any tray
in the targeted AMS has filament threaded into the feed tube
(tray.state === 11). The whole AMS rotates as one mechanism, so a
single loaded slot locks the entire unit. Previously the toggle was
always clickable and the firmware rejected with dry_sf_reason=[3]
(ConsumableAtAmsOutlet) after the click. The first cut keyed on the
printer-level tray_now but missed the H2D's typical post-print state
where tray_now resets to 255 while filament stays in the tube — the
per-tray state field reports it correctly. Submission also clamps
rotateTray off so a stale-true state from a previous AMS can't leak
through.
Backend: supports_drying_while_printing in printer_manager.py covers
display names and internal SSDP/MQTT codes (O1D, O1E/O2D, O1C/O1C2,
O1S, N6, BL-P001, N7, N9). New print_drying_enabled boolean in
settings schema. Frontend: toggle on SettingsPage, gate + clamp on
PrintersPage drying popover using existing amsData cache. i18n: 3 new
keys x 11 locales, no English fallback. Tests: 7 cases on the gate
matrix (TestSupportsDryingWhilePrinting), 4 cases on the scheduler
mid-print path (TestMidPrintDrying), 9 cases on the rotate gate state
transitions. Full backend pytest -n 30 green (4251/4251), ruff clean,
frontend npm run build clean, i18n parity 5355 leaves per locale.
fix(db): order filament_shopping_list color_name ALTER after CREATE
PR #1814 added ALTER TABLE filament_shopping_list ADD COLUMN color_name
before the CREATE TABLE IF NOT EXISTS for that table. On fresh installs
the ALTER hit "no such table" — not in _safe_execute's swallow list —
and aborted run_migrations, breaking every migration test that starts
from a fresh DB. Moved the ALTER to after the CREATE on both SQLite and
Postgres branches; the CREATE already declares color_name, so this is
purely the upgrade path and "duplicate column name" on re-runs is
swallowed.
Reprints triggered a bogus "Print Stopped" push notification while the print
kept running, surfaced by the reconciler synthesising a missed PRINT COMPLETE
on MQTT reconnect.
bambu_mqtt:3647 mints a fresh subtask_id per dispatch. On reprint, the
on_print_start expected-archive promotion only wrote subtask_id when the
stored value was empty (`not archive.subtask_id`) — so the archive kept the
FIRST run's id. On the next MQTT reconnect, reconcile_stale_active_prints
(#1542) compared the stale stored id against the printer's live id, found
a mismatch, and synthesised a status="aborted" PRINT COMPLETE — which fires
the "Print Stopped" notification.
Captured cleanly in the reporter's support bundle:
[RECONCILE] Printer 1: synthesising missed PRINT COMPLETE for archive 31
— subtask_id changed ('1844213296' → '2103771517')
immediately followed by gcode_state: RUNNING on the same wire.
Fix: update archive.subtask_id whenever the new effective id differs from
the stored one, not only when the stored one is empty. Inequality check
preserves the noop-on-stable-push behaviour the original guard provided.
Two places in main.py (expected-print and duplicate-printing-archive
branches). 3 new unit tests cover the reprint, first-run, and stable-push
paths. Reconciler itself unchanged — it was doing the right thing given
the data it had.
Brings the Spoolman writer up to parity with the internal-inventory
side, which has had this fallback since #1119. When a Bambu print
starts without a retrievable .gcode.3mf on the printer (typically an
unsaved BambuStudio project, subtask_name='Untitled'), Spoolman no
longer silently skips the print's filament consumption.
- ActivePrintSpoolman.filament_usage now nullable; new tray_remain_start
column captures per-slot {remain, tray_uuid} at print start.
- store_print_data: always snapshots remain, even when 3MF is present
(mirrors usage_tracker.on_print_start), so partial-3MF prints can also
fall back per-slot.
- report_usage: 3MF path stays primary; new _report_remain_delta_for_slots
handles slots the 3MF didn't cover via delta * Filament.weight / 100,
resolving the spool via the existing slot-assignment table.
- _report_partial_usage: same fallback for aborted no-3MF prints.
#1119 invariant preserved: per-slot, per-print, gated on a valid
start/current remain AND a resolvable Spoolman spool. Uses curated
Filament.weight (not MQTT's unreliable tray_weight) — same trick the
internal-inventory side uses.
Mid-print spool swap detected via tray_uuid mismatch → slot skipped.
Double-charge prevented via handled_global_tray_ids dedup.
The 7cb905a follow-up mounted the global unknown-tag modal listener, which
turned an existing always-on broadcast for no-tag slots from a silent no-op
into a perpetual popup loop — every push for a slot with a generic
non-RFID spool (or zero-filled tag) re-prompted, and confirming each one
created a fresh ghost spool with an empty tag.
- main.py on_ams_change: drop the no-tag else-branch broadcast. No identity,
no prompt; the slot stays unassigned until a real tag is read.
- inventory.py + spoolman.py /spools/from-slot: 400 when the slot has no
usable tag_uid / tray_uuid so stale frontends can't recreate the ghost
spool by re-confirming a queued prompt.
- test_inventory_from_slot_no_tag: lock the guard in (zero-filled + empty
string).
SpoolBuddy "Assign to AMS" with a Bambu Cloud user preset (PFUS) left
Bambu Studio showing "Generic <Material>" instead of the user's
custom preset. Root cause: the defensive filter that catches
PFUS/PFCN leaks into tray_info_idx also cleared setting_id —
but PFUS/PFCN are VALID setting_id values, just not valid
tray_info_idx values. When the cloud detail lookup didn't return
a filament_id (cloud unauth on the on_ams_change replay path,
transient failure, or older custom presets), both fields got
cleared and the caller's generic-material fallback overwrote
setting_id with GFSG99 — slicer resolved to Generic PETG.
Fix: the filter still clears tray_info_idx for PFUS/PFCN/material-
name leaks, but preserves setting_id when it's a valid slicer
reference (PFUS / PFCN / GFS). Material-name leaks still clear
both. Post-fix MQTT carries tray_info_idx=GFG99 (firmware-acceptable
for HMS/drying/colour) AND setting_id=PFUS<hash> (slicer uses this
to load the actual user preset).
What stays the same: Bambuddy's own AMS card still displays
the generic material on cloud-unauth paths — same fundamental
limitation as today. Fixing that needs a deeper layered fallback
(LocalPreset name match, printer kprofile query, cloud-detail
cache) and is out of scope for this drop. Slicer-side fix is
the reporter's explicit ask.