Reporter saw a 544 g spool jump to 1000 g after pressing the eraser.
"Spools and remaining weights are not changed" - the dialog promised
this; the implementation did the opposite. Root cause was an
architectural conflation: `weight_used` did double duty as the
resettable "consumed since tracking started" counter AND as the basis
for the displayed remaining (`label_weight - weight_used`), so zeroing
it correctly cleared the stat but unavoidably reset remaining to full.
Spoolman has separate `used_weight` and `remaining_weight` fields, so
the API call there was correct - but Bambuddy's frontend was also
computing remaining as `label_weight - weight_used` for Spoolman
spools (ignoring Spoolman's real `remaining_weight` field), so the
same visual bug bit there too. Inventory-mode parity required fixing
both halves in one drop.
Internal mode
- New `weight_used_baseline` column (Float DEFAULT 0) on `spool`.
- Reset stamps `baseline = weight_used` and leaves `weight_used` alone.
- Displayed consumed = `weight_used - baseline`; remaining =
`label_weight - weight_used` (unchanged).
- Subsequent prints continue to grow `weight_used`, so the resettable
counter naturally tracks post-reset delta and remaining keeps
decrementing across the reset.
Spoolman mode
- `_map_spoolman_spool` now reads Spoolman's `remaining_weight` field
and returns a synthetic `weight_used = label - remaining` so the
frontend's remaining calc matches Spoolman's real stored value;
`weight_used_baseline = synthetic - real_used_weight` so the consumed
counter (`weight_used - baseline`) matches Spoolman's `used_weight`.
- Fallback path (no `remaining_weight` set) preserves the old behavior.
- Related fix: `update_spool` (Spoolman PATCH) was deriving the default
`weight_used` from `used_weight`, so editing unrelated fields AFTER
a reset would patch Spoolman with `remaining_weight = label - 0 =
label`, trampling the real value. Now derives from
`remaining_weight` so non-weight edits preserve physical state.
Frontend
- `InventoryPage` `totalConsumed` aggregate switched to
`Math.max(0, weight_used - (weight_used_baseline ?? 0))`.
- `ForecastPanel` `computeDeltaRate`, `totalUsedG`, and the per-spool
"consumed" table cell got the same treatment so forecast and
inventory aggregates stay coherent across a reset.
- `?? 0` keeps pre-migration installs rendering correctly until
`init_db()` runs the idempotent ALTER TABLE.
Migration
- `ALTER TABLE spool ADD COLUMN weight_used_baseline REAL DEFAULT 0`
via `_safe_execute` - SQLite and Postgres both accept it; verified
end-to-end on Postgres 16.
Reporter Kyobinoyo asked for the equivalent of the existing
print-finish auto-off but triggered when AMS drying ends.
Two new SmartPlug columns: auto_off_after_drying (default false),
off_delay_after_drying_minutes (default 10 — AMS chamber is hot
post-cycle so longer cooldown than the print-finish default of 5).
SQLite + Postgres migrations both idempotent.
Trigger lives in BambuMQTTClient — per-AMS _previous_dry_times
tracks the dry_time > 0 → 0 falling edge and fires a new
on_drying_complete(ams_id) callback. Plumbed through
PrinterManager.set_drying_complete_callback to
SmartPlugManager.on_drying_complete(printer_id, db), which walks
linked plugs and respects the per-plug toggle. Catches queue,
ambient and manual drying identically because it observes firmware
state, not scheduler intent.
Frontend: single "Auto Off After Drying" toggle + delay input on
the smart plug card, next to the existing print-finish auto-off
section.
Per-AMS plug routing (separate plug for AMS only, per-AMS targeting
on dual-AMS printers) deferred — Bambuddy's plug model is
plug→printer, so the trigger fires whenever any AMS on the linked
printer finishes a cycle.
Reporter IndividualGhost1905 upgraded to 0.2.4.1 (which shipped the
per-event aggregation rewrite from #1378) and saw Quick Stats split
between consistent values (Total Prints, Print Time, Filament Used,
Energy, Success Rate matched the archive list) and zero-or-empty ones
(Filament Cost, Time Accuracy).
Root cause: #1378's migration added six columns to print_log_entries
- archive_id, cost, energy_kwh, energy_cost, failure_reason,
created_by_id - but never backfilled them. Pre-upgrade rows kept NULL
on all six. The new Quick Stats query sums PrintLogEntry.cost (gets 0
on legacy data); the time-accuracy query JOINs PrintArchive ON
archive_id (drops every legacy run from the average). Counts and the
pre-existing per-row fields (status, duration_seconds,
filament_used_grams) kept working - which is why some panels looked
right and others didn't.
Two-step backfill added inside run_migrations next to the existing
column-add block, as DML inside begin_nested() (not _safe_execute,
which is documented DDL-only):
Step 1: link each orphan log entry to its archive via
print_name + printer_id (highest archive id wins on
tiebreak - newest matches the overwrite-then-stop shape
pre-#1378 reprints left behind).
Step 2: copy archive.cost / energy_kwh / energy_cost onto the
latest matching log entry per archive, BUT only for
archives where no log entry yet carries a cost. That
second clause is the idempotency anchor and the
double-count guard for users running this after #1378
has already written cost-bearing rows for new runs -
those archives are left untouched.
Earlier reprints stay NULL, matching the "first/latest writes, rest
stay NULL" convention #1378 introduced for new prints. Sum across the
legacy reprint chain reproduces sum-of-archive-cost exactly, so Quick
Stats Filament Cost matches the pre-upgrade total instead of dropping
to zero.
SQL is plain ANSI - correlated UPDATE with LIMIT 1 in the SET
subquery, WHERE id IN (SELECT MAX(id) ... GROUP BY archive_id HAVING
SUM(CASE WHEN cost IS NOT NULL THEN 1 ELSE 0 END) = 0). Verified
end-to-end on SQLite (4 new unit tests in
test_print_log_backfill_migration.py: link-via-name, latest-run-gets-
cost, idempotent, skip-archives-with-any-costed-run) and against a
live postgres:16-alpine + asyncpg container (first-pass and second-
pass produce identical state).
The other widgets the reporter listed (Printer Stats, Filament
Trends, By Material, Success by Material, Color Distribution) iterate
the archives list on the frontend rather than calling /stats - they
read consistent pre-upgrade data and aren't part of this fix; the
inconsistency between them and Quick Stats resolves once the backfill
brings Quick Stats in line.
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.
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).
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.
fix(auth): cleanup orphan OIDC/MFA rows on user delete (#1285)
Three User-FK tables (user_oidc_links, user_totp, user_otp_codes)
declare ON DELETE CASCADE in their models, but SQLite ships with
PRAGMA foreign_keys=OFF (the project's existing pattern, mirrored
for APIKey in PR #1182). Without explicit DELETEs, deleting a user
on SQLite leaves orphan rows behind:
H2C / H2D AMS-HT units report ams_id 128+ (one ams_id per unit, single
tray), but spoolman_slot_assignments.ck_ams_id_range only admitted 0-7
and 255. Every attempt to link a Spoolman spool to an AMS-HT slot died
with `CHECK constraint failed: ck_ams_id_range`. The internal
spool_assignment table has no such constraint and works fine.
Widen the formula to (0-7) OR (128-191) OR 255 in the model, the
CREATE TABLE DDL, and an idempotent in-place migration for existing
installs (Postgres: DROP/ADD CONSTRAINT; SQLite: detect stale formula
in sqlite_master, rebuild via _v2 rename pattern).
Show an OrcaSlicer-style bed icon in the archive card's printer-name row
indicating which build plate the print was sliced for (Cool /
Cool SuperTack / Engineering / High Temp / Textured PEI / Smooth PEI),
with the full plate name in the hover tooltip. Closes the gap where
users had to remember which plate matched a re-print or open the
source 3MF in a slicer just to read the bed setting.
Card row also unified: archives with a real Bambuddy-printer
association used to render "H2D-1 GCODE ..." while slicer-only uploads
rendered "Sliced for X1C GCODE ..." -- same line, two different shapes.
Drop the "Sliced for " prefix so both render as a uniform
"<name-or-model> [bed-icon] GCODE <hash>" row, scanning identically
regardless of provenance.
Backend: new bed_type column on print_archives (idempotent ALTER TABLE
migration; SQLite + Postgres safe). Populated from curr_bed_type in
Metadata/slice_info.config (per-plate, authoritative -- that's what
got sent to the printer for the exported plate) with a fallback to
project_settings.config for older 3MF shapes. Wired through both
archive_to_response() (the hand-rolled dict converter that bypasses
from_attributes -- easy to miss) and the /rescan endpoint, so old
archives can be re-parsed via the existing per-archive Rescan button.
Backfill script (scripts/backfill_archive_bed_type.py, --dry-run
supported) re-opens every NULL archive's 3MF on disk to populate the
column. Auto-loads .env from project root before importing backend
modules (config.py reads DATABASE_URL from os.environ at import time,
not from pydantic-settings at Settings() time) and prints the resolved
DB URL with credentials redacted, so operators can confirm they're
hitting the intended database -- Postgres or SQLite.
Frontend: 6 OrcaSlicer-style PNGs ship in frontend/public/img/bed/ --
under /img/ because that path is already statically mounted; a
toplevel /bed-icons/ tried first hit the SPA catch-all and returned
index.html as text/html. New utils/bedType.ts maps slicer strings
case-insensitively, covering both Bambu Studio and OrcaSlicer naming
variants for the same physical plate. Unmapped or NULL bed_type
simply omits the icon, so cards stay clean for pre-feature archives.
The kiosk's Settings -> Update Daemon button returned "API keys cannot
be used for administrative operations" because POST /spoolbuddy/devices/
{id}/update was gated on Permission.SETTINGS_UPDATE, and SETTINGS_UPDATE
is in the _APIKEY_DENIED_PERMISSIONS deny-list introduced by PR #1241.
Every kiosk-side request tripped the deny-list before the API key's
scope set (Read / Print Queue / Control / Legacy) was even consulted.
Same root cause as the four QuickMenu System buttons fixed in 0.2.4b3
(Restart Daemon / Restart Browser / Reboot / Shutdown). Missed /update
in that audit on the reasoning "replaces the daemon binary, different
threat surface" — but that's wrong: restart_daemon already replaces
the running daemon process, so daemon-replacement is not a step up in
blast radius. The SSH update is also strictly scoped to the one device
the operator physically controls (git fetch + pip install + systemctl
restart on that host) — same threat profile as the system commands
already running on INVENTORY_UPDATE.
Lower /spoolbuddy/devices/{id}/update from SETTINGS_UPDATE to
INVENTORY_UPDATE so it aligns with the rest of the kiosk-scoped routes
(calibration/tare, display, cancel-write, system/command,
system/command-result, update-status). The main Bambuddy in-app updater
at POST /api/v1/updates/apply keeps SETTINGS_UPDATE — that one runs on
the Bambuddy host and is correctly fenced behind the deny-list.
feat(spoolman-inventory): squashed feature work for rebase onto dev
Squashed all commits from feature/spoolman-inventory-ui onto a single commit
to enable a clean rebase onto dev. Original per-commit history preserved at
backup tag backup/spoolman-inventory-ui-prerebase-20260507-105721.
chore(i18n): extend parity gate to all locales with strict/info tiers
Previously the script only inspected en/zh-CN/zh-TW, leaving de/fr/it/ja/pt-BR
drift invisible. Now locales are auto-discovered from src/i18n/locales/, and a
STRICT list (de, zh-CN, zh-TW — currently in parity) gates CI while the rest
report informationally until their drift is caught up. ja notably has 27 real
placeholder bugs worth fixing before promotion to strict.
feat(spoolman-inventory): squashed feature work for rebase onto dev
Squashed all commits from feature/spoolman-inventory-ui onto a single commit
to enable a clean rebase onto dev. Original per-commit history preserved at
backup tag backup/spoolman-inventory-ui-prerebase-20260507-105721.
go2rtc and several IP cameras still emit a warm-up / black frame on every
fresh MJPEG connection — even with the v0.2.4b2 warm-up-skip fix it
slipped through intermittently for @nkm8's setup. His own bisect named
the clean solution: go2rtc exposes /api/frame.jpeg as a dedicated
single-frame endpoint that never returns the encoder's stale keyframe.
Adds an optional external_camera_snapshot_url column on printers. When
set, every single-frame capture path (snapshot endpoint, [SNAPSHOT]
notification thumbnails, [PHOTO-BG] finish photo, layer timelapse,
Obico ML, plate-detect / calibrate-plate) routes through _capture_snapshot
on the override URL via plain HTTP GET, bypassing the warm-up dance.
Live view stays on the configured stream URL — only single-frame
captures use the override. Override is camera-type-agnostic. SSRF guard
applies (existing _sanitize_camera_url allowlist). Empty string treated
as unset.
Settings UI: new "Snapshot URL (optional)" input + Test button under
External Cameras, hidden for camera_type=snapshot since the live URL is
already a single-frame source. en + de fully translated; 6 other locales
seeded with English copy.
5 backend tests pin the routing contract; 3 frontend tests pin the
input + debounced PATCH. Documented in
bambuddy-wiki/docs/features/camera.md with the go2rtc example.
fix(oidc): use preferred_username/name claim for auto-created username
When auto-creating an OIDC user without a valid email claim, derive the
username from preferred_username or name IdP claims instead of falling
back to the opaque provider_sub[:30].
Edward's diagnosis was exact: the manual /print-queue/ POST extracts
filament requirements from the 3MF and writes
required_filament_types + filament_overrides + ams_mapping onto the
queue item, but the VP queue-mode write path skipped all of that.
Net effect: scheduler reached its model-only-matching fallback and
auto-dispatched onto whatever printer was free regardless of loaded
colour.
Extract the scheduler's existing _get_filament_requirements 3MF
parser into a shared helper so the VP path can reuse it. VP's
_add_to_print_queue now populates required_filament_types
unconditionally (cheap; helps the scheduler reject obvious type
mismatches) and writes filament_overrides with force_color_match:
true per consumed slot when a new per-VP queue_force_color_match
toggle is on. Default off to preserve current behaviour for
upgraders.
UI: new toggle on VirtualPrinterCard, mode-gated to print_queue,
mirroring the existing auto-dispatch toggle. i18n: en + de
translated, other 6 locales seeded with English copy.
Schema: one nullable column on virtual_printers
(queue_force_color_match BOOLEAN, default 0/FALSE).
11 new backend tests (8 for the extracted parser, 3 for the VP
write path) + 6 new frontend tests (toggle render gating, default
state, click posts queue_force_color_match in update body).
Existing scheduler tests pass against the refactored helper.
README, CHANGELOG, website features page, and wiki virtual-printer
page all updated.
@smandon retested the original #1152 fix on the latest daily and surfaced
two distinct holes:
1. ``Path(name).stem`` only strips the *last* suffix, so Bambu Studio's
default ``Plate_1.gcode.3mf`` exports landed in the archive UI as
``Plate_1.gcode`` — never the bare ``Plate_1`` the user expected.
2. The pending-uploads review card always showed the raw FTP filename,
while the eventual ``PrintArchive.print_name`` resolved from the 3MF's
embedded title (or, with the toggle on ``filename``, the stripped stem).
Net effect: same upload showed two different names depending on which
view you were looking at, with no way for the toggle to flip both
views in lockstep.
Three changes:
- ``resolve_display_stem`` helper in ``services/archive.py`` strips
``.gcode.3mf`` / ``.3mf`` / ``.gcode`` (case-insensitive). Applied at
the archive-creation site so ``Plate_1.gcode.3mf`` → ``Plate_1`` for
every flow that produces a ``PrintArchive`` row.
- ``PendingUpload.metadata_print_name`` (new nullable column) is
populated at FTP-receive time by peeking at the 3MF's embedded title
via the existing ``ThreeMFParser``. Read happens once per upload —
the list endpoint then doesn't have to reopen each 3MF on every
render. Parser failures are swallowed and the column stays NULL;
the response model gracefully falls back to the stripped filename.
- ``PendingUploadResponse.display_name`` is a computed field that
mirrors ``archive_print``'s exact precedence — ``filename`` toggle
→ stripped stem; ``metadata`` toggle (default) → cached title or
stripped stem. The frontend's review card reads it (with
``upload.filename`` as a defensive fallback) and surfaces the raw
FTP filename via tooltip so users can still inspect what arrived.
Migration is one idempotent ``ALTER TABLE pending_uploads ADD COLUMN
metadata_print_name VARCHAR(255)`` (Postgres/SQLite-safe). Pre-migration
rows have NULL and degrade to filename-stem behaviour without any
operator action.
Tests: 14 unit tests in ``test_archive_display_stem.py`` covering the
canonical normalisation rules (Bambu Studio default name, mixed case,
dots-in-the-middle, edge cases like ``.gcode.3mf``-only, full-path
inputs); 6 integration tests in ``test_pending_upload_display_name.py``
pinning the response contract (default toggle uses metadata title when
present, falls back to stripped stem when absent, ``filename`` toggle
overrides metadata, ``filename`` toggle still strips the double suffix,
``GET /{id}`` exposes the same field, whitespace-only metadata behaves
like absent); 3 frontend tests in ``PendingUploadsPanel.test.tsx``
pinning the review card's render path (resolved name shown, fallback
to filename when display_name is empty, raw filename available via
tooltip). Full backend suite: 3598 passed; frontend build clean; no
regressions in any flow that previously processed ``.3mf`` /
``.gcode`` / non-3D filenames.
Tim (@turulix) is building a fully automated headless slicing pipeline
against Bambuddy's API and hit the wall flagged in #665: /cloud/* routes
resolve cloud_token per-user from User.cloud_token, but the auth gate
returned None for API-keyed requests, so the route fell back to the
global Settings-table token, which only carries a value in auth-disabled
deployments. Net effect on auth-enabled deployments: API keys reached
the gate just fine, then /cloud/filaments always saw user=None and
returned 401 / empty results — no path to read slicer presets or the
filament catalogue that a CLI workflow needs.
Make API keys carry an owner and route /cloud/* lookups through that
owner; gate the new capability behind an explicit opt-in scope so
existing automation doesn't gain cloud-read access on upgrade.
- APIKey gains user_id (FK to users.id, ON DELETE CASCADE) and
can_access_cloud (BOOLEAN DEFAULT 0). User-delete route also runs an
explicit DELETE FROM api_keys WHERE user_id = ? since SQLite ships
FK enforcement off — same pattern as the existing created_by_id
cleanup blocks.
- New cloud_caller dep on /cloud/* routes resolves to the JWT user OR
the API-key owner stashed by a router-level gate. The auth gate itself
continues to return None for API keys so #1182's surface stays bounded
to /cloud/* — without that bound, any route that fences API keys via
`if current_user is None: raise 403` (e.g. long-lived-token
management) would silently start accepting them.
- The /cloud/* router-level dep enforces three independent fences for
API-keyed callers: user_id IS NOT NULL (legacy keys → 401 with
recreate copy), can_access_cloud=True (otherwise 403), and owner has
cloud_token (existing fence, unchanged). Two extra one-shot fence
errors at create/update time refuse can_access_cloud=True when auth
is disabled or the key is ownerless.
- Frontend: APIKey list shows "Cloud" badge on cloud-enabled keys and
"Legacy" badge on ownerless rows; create form gains an "Allow cloud
access" toggle, default off. New i18n keys in all 8 locales (en + de
fully translated, others seeded with English fallbacks pending native
translation — matches the project's flow for newly-added features).
Migration: two idempotent ALTER TABLE statements + an index on user_id
for the auth gate's owner→keys lookup. Postgres-safe.
Tests: 9 backend integration tests in test_api_key_cloud_access.py
covering creation flags, the three /cloud/* fences, JWT no-op, and
deletion CASCADE; 2 frontend SettingsPage tests pinning the badge
matrix and the create-form contract; 5 daemon unit tests for the
related SpoolBuddy ssh-key sync work that landed in the same branch.
Full backend suite: 3578 passed; full frontend suite: 1597 passed; no
regressions.
Permission semantics for existing keys: keys created before this
release become "legacy" and are rejected at /cloud/* with the recreate
message. Every other endpoint they were used against — queue, status,
control — is untouched.
Native-install upgrade via the in-app Apply Update button got the new
code in via `git reset --hard origin/main` but then logged
ERROR: Could not open requirements file:
[Errno 2] No such file or directory: 'requirements.txt'
and continued. The new deps never installed, leaving the user with
new code but stale dependencies — surfaces as cryptic import errors
on the next restart.
Root cause: `pip install -r requirements.txt` ran with
`cwd=settings.base_dir`. On a native install, systemd sets
DATA_DIR=$INSTALL_PATH/data so base_dir resolves to the data dir
(e.g. /opt/bambuddy/data), not the source tree. Pip doesn't walk up
looking for the requirements file the way git walks up looking for
.git, so it fails. Same bug affected the optional npm step
(`frontend_dir = base_dir / "frontend"` doesn't exist).
Fix: introduce `settings.app_dir` pointing at the source-tree root
(distinct from `base_dir` only on native installs) and run pip +
npm with `cwd=settings.app_dir`. Git ops keep using `base_dir`
because they already work (git walks up).
Docker users were unaffected — Docker doesn't use the in-app updater
(image pull replaces it).
Regression test in test_updates_api.py mocks every subprocess in
_perform_update, captures their cwd, and asserts the pip step runs
in app_dir and that requirements.txt actually exists there. Any
future refactor that re-introduces cwd=base_dir for the pip step
fails CI before another user trips over it.
Spool and color_catalog rows carry extra_colors (comma-separated hex
stops) and effect_type (14 visual variants: surface effects, sheen,
structural). The shared FilamentSwatch component renders gradient,
conic, effect overlay, and alpha-checkerboard consistently across the
inventory grid, table, group banner, card, ColorSection preview, and
catalog editor. Catalog hex_color accepts #RRGGBBAA so catalog entries
can carry transparency too.
The paste field accepts the exact format 3dfilamentprofiles.com puts on
its filament details pages, so users can copy a multi-colour combo
directly. The effect dropdown spans the full filament-variant
vocabulary -- surface effects (sparkle/wood/marble/glow/matte), sheen
variants (silk/galaxy/rainbow/metal/translucent), and structural
variants (gradient/dual-color/tri-color/multicolor). None of these
fields touch MQTT/firmware -- pure visual hint.
Spool group-key extended to include extra_colors + effect_type so
"Group similar" no longer collapses visually distinct spools.
Migrations: 4 idempotent ALTER TABLE ADD COLUMN (Postgres-safe), plus
ALTER COLUMN hex_color TYPE VARCHAR(9) on Postgres only (SQLite ignores
VARCHAR length).
Tests: 42 new backend (35 unit + 7 integration), 20 new frontend (14
FilamentSwatch + 3 ColorCatalogSettings + 3 InventoryPageGrouping
regression). 3522 backend + 1582 frontend tests pass; ruff clean.
Localised across all 8 UI locales.
Two new project fields: a free-text URL rendered as a one-click
external-link button beside the project name on every card (opens in a
new tab, click is e.stopPropagation()-guarded so it doesn't enter the
project), and a cover photo that replaces the status-icon box with a
square thumbnail.
URL is plumbed through ProjectCreate/Update/Response/ListResponse,
including from-template + create-template flows so it inherits between
a project and its template. Cover photo is not inherited because the
file would be shared on disk between source and copy.
Schema validator rejects anything other than http:// or https://
prefixes -- <a href> rendering would otherwise execute javascript:
/ data: / file: URLs even with React's default escaping. PATCH uses
model_fields_set for the URL field so users can clear it by sending
{"url": null}.
Cover image storage: Project.cover_image_filename references a file
Cover image storage: Project.cover_image_filename references a file
inside the existing archives/projects/{id}/attachments/ dir, but it's
tracked separately from the attachments JSON list so swap/delete on
the cover doesn't perturb the user's other attachments. Three routes
(POST/GET/DELETE /projects/{id}/cover-image) accept only .jpg/.jpeg/
.png/.gif/.webp (no SVG -- SVG can carry script payloads), replace in
place (prior file deleted before the new one lands so repeat uploads
can't accumulate orphans), and self-heal when a DB reference points at
a vanished disk file by clearing the column and 404'ing.
GET cover-image is gated by RequireCameraStreamTokenIfAuthEnabled
(accepts ?token=... query string) -- not the bearer-token gate -- so
<img src> requests work in both auth-on and auth-off configurations.
The frontend wraps getProjectCoverImageUrl with withStreamToken(),
matching the existing pattern from getArchiveThumbnail.
Permissions: PROJECTS_UPDATE for upload/delete/PATCH, PROJECTS_READ
gate is implicit via the stream-token credential. Migration: 2
idempotent ALTER TABLE projects ADD COLUMN. Localised across all 8
UI languages.
@Carter3DP's support package showed bambuddy.log filling with two
distinct cascades on long uploads:
ERROR sqlalchemy.pool Exception terminating connection ...
CancelledError: Cancelled via cancel scope
... by starlette.middleware.base
.BaseHTTPMiddleware.__call__.call_next
ERROR sqlalchemy.pool The garbage collector is trying to clean up
non-checked-in connection ... will be
terminated.
WARN backend.app.main Runtime tracking commit failed:
(sqlite3.OperationalError) database is locked
Single root cause. Starlette's BaseHTTPMiddleware (used under the hood
by every @app.middleware("http") decorator) cancels the inner task
scope when a client disconnects mid-request — common on long
multipart uploads where the client times out before the server's
response. Pre-fix get_db only caught Exception, but CancelledError
is BaseException, so cancellation skipped the rollback path entirely.
The SQLite write lock stayed held until GC reclaimed the connection
ages later, blocking every other writer in the meantime. On Postgres
the leak shape is identical; the symptom would be "QueuePool limit
... overflow" instead of "database is locked".
(1) get_db now catches BaseException so CancelledError triggers
rollback. Both rollback() and close() are wrapped in
asyncio.shield so the cleanup completes even when the await
itself is being cancelled by the same cancel scope. SQLite write
lock is released promptly; connection returns to the pool instead
of leaking until GC.
(2) CancelledPoolNoiseFilter (new filter on sqlalchemy.pool) drops
the residual records that pre-existing pools still emit during
their own cleanup. Two patterns suppressed:
- "Exception terminating connection ..." with a CancelledError
anywhere in the exc_info chain (walks __cause__/__context__
with a seen-set guard against pathological cycles)
- "The garbage collector is trying to clean up non-checked-in
connection ..." (always symptomatic of cancellation; never
independently actionable)
Real pool problems — broken connections, OSError on terminate,
pool exhaustion — keep flowing because they carry a different
exception chain or a different message prefix.
13 regression tests across test_get_db_cancel_safety.py (commit on
clean exit, rollback on regular Exception, rollback on CancelledError,
close runs even if rollback raises, close failure on clean exit
doesn't propagate, rollback + close both go through asyncio.shield)
and test_cancelled_pool_filter.py (drops cancellation-driven
terminate, drops GC-cleanup, keeps real OSError terminate, keeps
terminate without exc_info, keeps unrelated pool messages, drops
chained-cause CancelledError, defensive guard against self-referential
cause chains).
Applies to SQLite and PostgreSQL — get_db is dialect-agnostic and
the filtered messages come from base sqlalchemy.pool not from any
specific dialect.
bambuddy.log on Windows fills with
Exception in callback _ProactorBasePipeTransport._call_connection_lost()
ConnectionResetError: [WinError 10054] An existing connection was
forcibly closed by the remote host
every time a printer / MQTT broker / camera RSTs a TCP socket instead
of FINing it. The application-layer reconnect (paho-mqtt, httpx)
handles the actual disconnect fine; the traceback is asyncio
bookkeeping. Reported by @cadtoolbox who runs 9 printers including 5
offline X1Es, so the log filled multiple times per minute.
New backend/app/core/asyncio_handlers.py installs a custom
loop.set_exception_handler on Windows that pattern-matches three
signals together (platform == win32, exception is
ConnectionResetError, asyncio message contains
_call_connection_lost) and demotes the entry to DEBUG. Genuine
ConnectionResetErrors raised inside application coroutines have a
different message string and still surface; BrokenPipeError /
ConnectionAbortedError on the same cleanup path also still surface.
Wired from lifespan startup before any task can spawn that might
trip it. Linux / macOS use the Selector loop, so install is an
explicit no-op there with a False return.
9 unit tests in test_asyncio_handlers.py covering signature match,
rejection of unrelated resets, platform gate, suppress vs.
pass-through to default handler.
Adds an optional slicer-api/ Compose stack and wires Bambuddy's File
Manager, Archives, and MakerWorld pages to a new server-side Slice flow.
Slicing runs as an in-memory background job (POST returns 202 + job_id,
polled via GET /api/v1/slice-jobs/{id}) so a multi-minute slice no
longer pins the modal; result lands as a new .gcode.3mf in the same
folder (or new archive for archive sources) with the embedded
thumbnail extracted.
Backend
- New services: slice_dispatch (in-memory dispatcher, 30min retention
sweep) and slicer_api (HTTP bridge with 4xx/5xx/connection error
split that drives the 3MF embedded-settings fallback retry path).
- New schemas: SliceRequest, SliceResponse, SliceArchiveResponse,
SliceJobEnqueueResponse.
- New routes: POST /library/files/{id}/slice,
POST /archives/{id}/slice, GET /api/v1/slice-jobs/{id} (gated on
LIBRARY_READ since job IDs are sequential and the body leaks source
filenames and result IDs).
- AppSettings + env defaults: use_slicer_api, orcaslicer_api_url,
bambu_studio_api_url. DB-stored values override env defaults.
Frontend
- New SliceModal handles preset gating; enqueues then closes
immediately.
- New SliceJobTrackerProvider polls active jobs at app level, surfaces
a single toast per job (queued -> running -> completed / failed)
and invalidates library/archives queries on terminal status.
- Settings -> Workflow -> Slicer card: preferred slicer dropdown,
Use Slicer API toggle, contextual sidecar URL field.
- File Manager / Archives / MakerWorld get a Slice button gated on
the Use Slicer API setting.
- gcode-viewer adapter learns ?library_file=<id> so sliced library
files preview inline.
i18n
- New slice.* and settings.{useSlicerApi,slicerCard,orcaslicerApiUrl,
bambuStudioApiUrl,slicerApiUrlDescription,useSlicerApiDescription}
+ fileManager.noPermissionSlice keys across all 8 locales (en, de,
fr, it, ja, pt-BR, zh-CN, zh-TW). English fully translated, German
fully translated, the other six seeded with English fallbacks
pending native translation.
Tests
- 10 backend integration tests in test_library_slice_api.py covering
validation (404/400), happy-path enqueue, sidecar-down, 3MF
embedded-settings fallback, STL no-fallback, and preset-error ->
failed job paths.
- New unit tests in test_slicer_api.py for the HTTP bridge.
- 5 new SliceModal frontend tests covering preset gating, library +
archive enqueue paths, error surface, and preset-load failure.
- Existing SettingsPage tests adjusted: slicer dropdown asserts now
switch to the Workflow tab first; added a beforeEach URL reset so
one test's tab click doesn't bleed into sibling tests.
Sidecar
- New slicer-api/ folder is self-contained and optional. Two services
(orca-slicer-api on 3003, bambu-studio-api on 3001 behind --profile
bambu) build via Docker git-build-context from
maziggy/orca-slicer-api@bambuddy/profile-resolver. The fork patches
the OrcaSlicer CLI's profile compatibility quirks (inherits-chain
resolver, from:User -> system rewrite, '# ' clone-prefix strip,
sentinel-value strip) empirically required to slice real GUI
exports without segfaulting the CLI.
Docs
- CHANGELOG entry under [0.2.4b1] - Unreleased Added.
- README File Manager bullet for the new server-side Slice button.
- bambuddy-website features.html: new card under "Configurable Slicer".
- bambuddy-wiki: new page features/slicer-api.md + nav entry +
features index card.
Notes
- Opt-in: with Use Slicer API off, the existing "open in desktop
slicer via URI" flow is the default and unchanged.
- 3MF inputs that segfault the CLI on --load-settings transparently
retry with embedded settings; the resulting job carries
used_embedded_settings: true.
- Sliced files always export as .gcode.3mf so File Manager picks up
the embedded thumbnail; file_type is set to "gcode" (blue badge).
Builds on the recent uvicorn-access-log-into-bambuddy.log change.
Until now the access line told us who called an endpoint, but there
was no way to tie that line to the application records emitted on the
server side while handling that request. The rogue stop_print mystery
on 2026-04-26 left exactly that gap: even with access logs piped in,
correlating "this POST landed" with "this MQTT publish went out 6 ms
later" required eyeball-matching timestamps across different loggers.
A new ContextVar + middleware + logging filter wire a trace ID through
every record:
* trace_id_middleware mints an 8-char hex ID per request (or honours
a sane inbound X-Trace-Id for cross-system correlation), stores it
in trace_id_var (ContextVar), echoes it on the response as
X-Trace-Id, and resets the var in finally.
* TraceIDFilter, attached to root + uvicorn.access, copies the
current trace_id_var value onto every LogRecord so the format
string [%(trace_id)s] resolves to the right ID per record.
* Records emitted outside any request scope (startup, MQTT
callbacks, scheduler) get a stable "-" placeholder so the column
stays visually aligned and grep stays simple.
ContextVars are the right plumbing because asyncio copies the current
context into every asyncio.create_task, so background work spawned
from inside a request inherits the same ID without explicit threading.
request.state can't make that hop. The logging filter also has no
access to the FastAPI request object — it runs synchronously inside
the stdlib logging machinery — and the ContextVar is the only
mechanism that bridges async request scope to sync log emission.
Inbound X-Trace-Id is hard-validated against [A-Za-z0-9_-]+ (max 64
chars) before being honoured — a hostile/buggy caller cannot smuggle
log-injection payloads (newlines, control chars, megabyte blobs) into
bambuddy.log via the trace ID column; values that fail the gate
silently trigger a freshly minted server-side ID rather than failing
the request.
Middleware is decorated AFTER auth_middleware on purpose: Starlette
stacks @app.middleware decorators LIFO so the last-decorated runs
first inbound, making trace stamp the OUTERMOST layer — auth log
lines and every record emitted on the way down to and back from the
route handler all carry the same ID.
Output now correlates as:
2026-04-26 09:51:39,152 INFO [uvicorn.access] [a4f3b1e7] - "POST
/api/v1/printers/1/print/stop HTTP/1.1" 200
2026-04-26 09:51:39,158 INFO [bambu_mqtt] [a4f3b1e7] [SERIAL] Sent
stop print command
One grep a4f3b1e7 returns the full causality chain.
30 new tests: 22 unit (ContextVar placeholder, filter copies value,
asyncio task propagation, concurrent-request isolation, hex generator
uniqueness, hostile-payload validator, max-length boundary, all four
write verbs survive, GET/HEAD/OPTIONS dropped, URL-substring false-
match guards, edge cases) and 8 integration (X-Trace-Id round-trips,
body matches header, hostile inbound replaced, overlong inbound
replaced, ContextVar resets after request, generator format stable,
each request gets unique ID).
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).
#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.
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.
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.