Two-part fix for the #1322 follow-up by @RosdasHH.
Data layer.
The previous narrow heuristic in printer_manager.py only caught
the bare {"id": N} payload firmware sends right after a printer
restart. In steady-state operation — and on the more common
post-Reset-Slot path on P1S and A1 Mini BMCU — firmware sends a
populated payload and signals emptiness via the tray_exist_bits
bitmask. We already parse that bitmask and use it to wipe stale
tray_type / tray_color / tag_uid fields, but never touched the
state field, so downstream readers (printers.py API serializer,
inventory.py's tray_state in {9, 10} short-circuit, AMS card)
saw state: null and had to guess from absent payload fields.
Fix lifts tray["state"] = 9 (int — not "9"; inventory.py:1358
uses == not `in {...}` so a string would silently miss and the
reporter's deadlock would come back) to the outer `if not
slot_exists` branch, so the bitmask path now writes the
canonical "no spool" code for every empty slot regardless of
stale fields. The narrow heuristic in printer_manager.py:797
stays as belt-and-suspenders for any MQTT path that doesn't
flow through _handle_ams_data.
UI layer.
With the data flow now consistent, the AMS slot card renders
physically-empty slots distinctly from reset slots, per
reporter's mockup. New helper getEmptySlotKind(tray) returns
"physical" (state ∈ {9, 10}), "reset" (any other empty state),
or null (loaded). The inline label below the slot circle reads
"Empty" for physical and "Reset" for reset; pre-fix both showed
an em-dash. FilamentSlotCircle gains an emptyKind prop that
picks a quieter dashed border colour for reset slots so the
visual hierarchy reads loaded > reset > physically empty.
EmptySlotHoverCard gains a kind prop and switches between
"Empty slot" and "Slot reset — no spool assigned".
Three independent root causes behind the divergences the reporter
flagged after the archived-spool fix shipped — fixed together.
(1) Filament Used vs Total Consumed.
_compute_run_filament_grams returned the slicer estimate for completed
prints even when inventory had measured the actual AMS weight delta.
That made Stats and Inventory two different sources of truth: Stats
showed slicer-estimate grams, Inventory showed AMS-tracked grams, and
the two never agreed. Reordered the helper so the tracked spool delta
(same source that drives weight_used behind Total Consumed) takes
priority for every status. Slicer estimate stays as the fallback when
no inventory was tracked; partial-progress scale stays as the fallback
for failed/cancelled with no tracker. The _run_cost block right next
to it was already tracker-first; only filament_used_grams was
inconsistent.
(2) Printer Stats By Time vs Quick Stats Print Time.
/archives/slim only set actual_time_seconds when status == "completed".
For failed/cancelled rows the frontend fell back to print_time_seconds
(the slicer's full-print estimate — wrong number for a print that
failed at 15%). Quick Stats already summed elapsed duration across
all statuses, so the two halves of the page disagreed by the
(estimate - actual-elapsed) gap on every non-completed event. Dropped
the completed-only gate; failed/cancelled now report measured elapsed.
(3) Success Rate %.
Was successful / (successful + failed), excluding cancelled / stopped
from the denominator. With "Total Prints: N" displayed right above
the gauge that produced confusing numbers — 4 successful, 0 failed,
48 cancelled showed 100% out of an apparent 52 prints. Switched to
successful / total_prints — matches the count the user reads from
the widget header.
Reporter (@IndividualGhost1905) saw archived spools' consumption
silently drop out of the Total Consumed running total — un-archiving
put it back. The stat is lifetime-since-reset, not currently-
available, so archived consumption belongs in it.
InventoryPage.tsx stats loop split: totalConsumed sums over all
spools (active + archived); totalWeight, lowStock, byMaterial,
activeCount keep their archived-skip.
Also fixes two adjacent UX gaps the reporter surfaced:
- Per-spool eraser button was gated on !archived_at && weight_used
> 0. Dropped the archived gate so an archived spool's tracking
counter can be zeroed without un-archiving first.
- activeSpoolIds (target of "Reset all usage" bulk action) excluded
archived. Renamed to resetableSpoolIds and broadened to include
archived so Reset-all genuinely zeroes the now-broader stat in
one click. Backend reset endpoints already accept archived IDs.
Reporter on a 27" monitor saw long filenames truncated even though
the Print Log table had plenty of unused horizontal space. The
print-name `<span>` had a hard `truncate max-w-[200px]` cap that
ignored viewport width entirely.
Replaced with `break-words` + a `title` attribute, dropping the
explicit max-width so the column auto-sizes to content. On wide
screens the full name shows on a single line; on narrow ones it
wraps inside the cell instead of forcing horizontal scroll. The
`title` hover preserves the original tooltip affordance for the
edge case where a really long name still gets wrapped.
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.
Several support reports traced back to one root cause: a mistyped access
code in Add Printer left an empty card on the dashboard. POST /printers/
was persisting the row first, then firing connect_printer() fire-and-forget.
Now we test_connection() BEFORE the insert; failure returns HTTP 400 and
the row is never written.
Structured error response -- detail={"code", "message"} -- so the toast
shows the localized message instead of the English fallback. New
ApiError.code field on the frontend; printers.toast.connectionFailedNotAdded
The LabelTemplatePickerModal correctly branches on a spoolmanMode prop
and the /spoolman/labels backend endpoint exists, but InventoryPage was
instantiating the modal with spoolmanMode={false} hard-coded. Every
click in Spoolman mode resolved to /inventory/labels with Spoolman spool
IDs and returned 404 "Spool(s) not found".
The hard-coded value came with a stale comment from the original label
printing PR that said "Spoolman path hands users an iframe to Spoolman
so the per-spool button never shows in that context" -- that assumption
stopped being true when the unified inventory UI shipped.
Pass the actual spoolmanMode value through, drop the stale comment.
The existing LabelTemplatePickerModal.test.tsx already covers both
branches at the component level (line 209: "routes to the Spoolman
endpoint when spoolmanMode is true"). The gap was that no test exercised
the InventoryPage wiring.
First cut of this action only wired the built-in inventory path, so the
eraser buttons vanished when the user switched to Spoolman mode. Mirror
the endpoints on the Spoolman router:
- POST /spoolman/inventory/spools/{id}/reset-usage
- POST /spoolman/inventory/spools/reset-usage-bulk
Both route to a new SpoolmanClient.reset_spool_usage() helper that PATCHes
/spool/{id} with used_weight=0. The bulk variant keeps the same typo-wipe
guard (rejects empty/missing spool_ids), and individual Spoolman failures
are logged + counted out without aborting the batch.
InventoryPage mutations now switch on spoolmanMode to pick the right
client method, and the three "spoolmanMode ? undefined : ..." gates on
the eraser buttons are gone.
Reporter pgladel manages multiple physical filament storage
locations and wanted to narrow the inventory list to a specific
location without typing a search query each time.
Adds a new Storage Location dropdown chip on the inventory page,
next to the existing Material / Brand / Category / Spool Name
filters. Distinct values are pulled from the spool list with
.trim() so accidental trailing whitespace doesn't render as a
separate option. A "No location set" entry appears when at least
one spool has an empty storage_location (mirrors the categoryNone
group). Chip self-hides when no spool has a storage location set.
Same shape as the Category chip from #729 — clear-all-filters and
hasActiveFilters both include the new state.
i18n: reuses existing inventory.storageLocation label, adds
inventory.storageLocationNone in all 8 locales. Parity check
holds at 4818 leaves per locale. 24 InventoryPage tests still
pass, frontend build clean.
Reporter IndividualGhost1905 followed up after the #1378 / #1343
backfill landed and pointed at the next inconsistency: the per-
archive delete dialog has had a "Also remove this print from Quick
Stats" checkbox since #1343, but the "Purge Old" button and the
scheduled daily auto-purge sweeper both ignored that choice and
hard-deleted unconditionally. From the user side this looked like
"automatically deleted from statistics without any warning" — half-
true, and the inconsistency was real either way.
The actual current shape (before this fix):
- POST /archives/purge -> archive_purge_service.purge_older_than
-> ArchiveService.delete_archive (hard). Archive row dropped.
Linked PrintLogEntry rows have ON DELETE SET NULL so they
survive as orphans with archive_id=NULL. Quick Stats keeps the
filament / cost / energy contribution because the log rows are
still there, but the archive-list-iterating widgets (Filament
Trends, By Material, Color Distribution, Printer Stats) lose
the row, and Time Accuracy loses its join target. Visibly
inconsistent.
- Scheduled _maybe_run_auto_purge -> same code path, same effect.
- Single-archive DELETE /archives/{id} -> already takes
purge_stats=true|false (default false=soft) and routes either
soft_delete_archive (keeps everything, flips deleted_at) or
deletes PrintLogEntry rows first + hard-deletes archive.
The fix threads the same purge_stats flag through every bulk surface
with soft as the default, matching the single-archive default:
Backend:
- archive_purge_service.purge_older_than(..., purge_stats=False)
-> per-row soft_delete_archive when False, per-row
PrintLogEntry deletion + delete_archive when True. Each runs in
its own session (same pattern the sweeper already used).
- preview_purge gains the same kwarg so the eligible-count
matches what an actual run would touch: soft mode excludes
already-soft-deleted rows, hard mode counts them as eligible
for promotion.
- get_settings / set_settings now persist archive_auto_purge_stats
(default False). _maybe_run_auto_purge reads it on every tick.
- ArchivePurgeRequest / ArchivePurgeResponse / ArchivePurgeSettings
schemas extended.
- Route /archives/purge accepts the body flag, /purge/preview
accepts the query param, /purge/settings GET + PUT echo the
setting.
Frontend:
- "Purge old archives" modal: new "Also remove from statistics"
checkbox under the preview, unchecked by default. Plumbed into
the preview query key + the execute mutation.
- Settings -> Archives auto-purge card: matching toggle next to
the days slider, disabled when auto-purge itself is off.
- api.previewArchivePurge / api.executeArchivePurge accept the
flag; ArchivePurgeSettings type gains purge_stats.
- All 8 locales (en, de, fr, it, ja, pt-BR, zh-CN, zh-TW) get
new purgeStatsLabel / purgeStatsHint / purgeStatsDescription
keys, plus rewritten effect / warning copy in archivePurge and
archiveAutoPurge to reflect that the default no longer
"permanently removes from the database" but instead hides the
row + removes files while keeping Quick Stats intact. i18n
parity check clean: 4814 keys across all 8 locales, no fallback.
Behaviour change for existing users on auto-purge: the sweeper used
to hard-delete by default and now soft-deletes by default. After
the upgrade those installs start *preserving* more data in Quick
Stats rather than losing it — safer direction of the two, but worth
the explicit call-out. Anyone who wants the old behaviour ticks the
new toggle once and it persists.
Opening the Print Queue page fired 404s on /archives/{id}/thumbnail,
/archives/{id}/plates, and /archives/{id}/plate-thumbnail/{n} for any
row pointing at a soft-deleted archive. Two underlying problems wearing
one mask:
1. Cosmetic: the queue API was copying item.archive.thumbnail_path into
archive_thumbnail without checking deleted_at. Soft-delete leaves the
row (so the relationship resolves) but removes the file from disk, so
the cached path was always stale.
2. Functional: a queue item whose 3MF was removed can never dispatch.
Without an explicit cancel, the item sits in 'pending' forever with
no indication to the user about why nothing is printing.
Fix in three parts:
- New _cancel_pending_queue_items() helper, called from soft_delete_archive
alongside the existing print-log thumbnail cleanup. Sets status='cancelled'
+ waiting_reason='Source archive deleted' on every pending queue item
linked to the archive. Only 'pending' is touched - completed/failed/
cancelled rows are historical and untouched. Hard-delete is already
covered by ON DELETE CASCADE on print_queue.archive_id.
- Queue API serializer now checks item.archive.deleted_at before
populating any archive-derived field. New archive_deleted: bool field
on PrintQueueItemResponse signals the soft-deleted state.
- Frontend's getArchivePlates query in QueuePage was gated on archive_id
only - archive_id is the real FK and stays exposed for dispatch/audit,
so added an explicit && !item.archive_deleted clause to respect the
new flag. Thumbnail render and CompactHistoryRow/QueueTimelineView
already gate on archive_thumbnail so the backend suppression alone
covers them.
Regression tests pin cancel-only-pending behavior, soft-deleted
suppression + archive_deleted=True flag, and the sanity guard that
live-archive fields keep flowing through unchanged.
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).
Reporter @Fuechslein flagged that disabling LDAP auto-provision left admins
with no UI path to onboard new users — the create-user form had zero LDAP
awareness and the only workaround was hand-editing the database.
Add a Local / LDAP tab toggle to the create-user modal (hidden when LDAP is
disabled). The LDAP tab is a debounced directory search (≥2 chars, 300ms)
that returns up to 25 matches via the service-account bind, annotated with
already_provisioned so existing usernames render disabled. Clicking
"Provision user" re-resolves via the service bind and creates the user
through the same _provision_ldap_user helper the auto-provision login path
uses, so group mapping, default-group fallback, and email sync are identical
regardless of which path created the user.
The picker component is shared across all four create-user modal paths
(UsersPage basic + advanced, SettingsPage basic + advanced).
Two ldap3 schema-check workarounds were needed for OpenLDAP installs:
- Open the search connection with check_names=False so ldap3 doesn't reject
the cross-schema OR filter (sAMAccountName/displayName are AD-only)
- Request attributes=["*"] because ldap3's build_attribute_selection
validates each named attribute against the server schema regardless of
check_names, and only the * wildcard is in its hard-coded exclusion list
Login/lookup paths keep check_names=True so typos in user_filter still fail
loudly.
Backend
- New routes: GET /auth/ldap/search, POST /auth/ldap/provision (both gated
by USERS_CREATE; 503 details include ldap3 exception class + message)
- Extract _open_service_connection + _extract_user_info helpers so
authenticate_ldap_user, lookup_ldap_user, and search_ldap_users share the
bind and attribute-extraction logic
Frontend
- New LdapUserPicker component (debounced search, result list, provision
mutation, already-provisioned guard, error surface)
- Tab toggle wired into UsersPage and SettingsPage modals, plus
CreateUserAdvancedAuthModal props
- 14 i18n keys added to en.ts (other locales fall back to English)
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.
Create/Edit User modal previously had no hint about backend password
complexity, and the FE pre-check was only length>=6 — so any weak
password got bounced as a bare "HTTP 422" toast after the round-trip.
- New checkPasswordComplexity util mirroring backend's validator order
- Helper text under password inputs in both modals
- Submit disabled until every rule passes
- client.ts 422 parser falls back to JSON.stringify(detail) when the
mapped array is empty, so a bare status code never masks the real
Pydantic detail again
- i18n keys added in all 8 locales (EN/DE fully translated, others
per project's English-seed convention)
- 7 unit tests pinning the validator contract, including the
reporter's "12345678" input
The "Open Cloud settings" link in the MakerWorld sign-in-required banner
pointed at /settings?tab=cloud, but Settings has no cloud tab so the URL
fell back to the General tab. Bambu Cloud login lives on /profiles, which
already defaults its sub-tab to cloud.
The Clear Plate button (and 4 other features on the Printers page) read
their state from /settings, which requires SETTINGS_READ. Granting that
permission also adds the Settings nav item and leaks SMTP/LDAP/MQTT
credentials — exactly what users were trying to avoid by giving an
operator only printers:clear_plate.
New /settings/ui-preferences endpoint returns a curated, opt-in subset
of non-sensitive fields. Matches the existing /default-sidebar-order
precedent. PrintersPage switched to the new endpoint; admin pages still
use /settings for full access.
Plugs reporting fractional watts (Kauf PLF12 / ESPHome via MQTT)
overflowed the card width. SmartPlugCard and SwitchbarPopover
already round the same field; only the printer-card badge was raw.
Smart plugs with "Show on Printer Card" enabled appear as a chip in the
HA-entities row below the main plug controls. One click cut power to the
printer instantly — including mid-print — while the main Off button right
next to it already routes through a ConfirmModal. The HA-row chip was
added later and skipped the same gating.
Branch on entity type: script.* entities keep firing instantly (fire-once
triggers, not power switches — confirming each click would be annoying),
but switch/light/anything-else entities now open a ConfirmModal first.
Reuses the same variant="danger" + running-print warning copy as the
existing power-off confirmation when status.state === 'RUNNING'.
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.
Opening the GCode Viewer from a File Manager card or Archive card mounts
GCodeViewerPage as a full-height iframe inside the Layout shell. The page
rendered nothing but the iframe, so once the third-party viewer's UI took
over the content area there was no in-app affordance to return to the
originating list - only the browser's back button.
Add a thin bar above the iframe with an ArrowLeft button. The label adapts
to the entry point - "Back to Print Archives" when the URL carries
?archive=, "Back to File Manager" when it carries ?library_file=, generic
"Back" otherwise. Click prefers navigate(-1) so the user lands back in
their original list with scroll position and filters preserved; falls
back to /archives or /files when the page was opened in a fresh tab and
there's no SPA history to return to.
New gcodeViewer.{back, backToArchives, backToFiles} i18n namespace added
to all 8 locales with native translations.
The archive card's action row crams 6 buttons into one line: 2 labelled
(Reprint + Schedule, or Slice when un-sliced) plus 4 icon-only utilities.
The labelled buttons used `flex-1` to share whatever the icon buttons
left over, with the label gated on `hidden sm:inline truncate`.
Tailwind viewport breakpoints can't see the card width. The grid grows
columns alongside viewport (md:2 lg:3 xl:4), so cards stay ~320-380 px
wide regardless of breakpoint, and the labelled buttons end up with
~30 px of space — enough to render "Re..." / "Sc..." and not much else.
Bump the label breakpoint sm: -> xl: so labels appear only at
viewport >= 1280px where the cards actually have room. Below that,
the buttons render icon-only and the existing title= attribute serves
as the hover tooltip.
Two defects in buildFilamentOptions, surfaced together:
1. The function was precedence-based — cloud presets short-circuited
the local-presets branch, silently hiding any imported Local Profile
while the user was logged into Bambu Cloud. The wiki documents the
dropdown as "merged and deduplicated" across cloud + local + built-in.
2. Cloud default presets and local presets were being collapsed by base
name (everything after "@" stripped), so all P1S/X1C/A1 variants of
"Bambu PLA Basic" rendered as a single row. The spool form is
printer-agnostic by design, so the right semantic is to show every
variant individually — the union across all printers — not collapse
them. AMS Slot is per-printer (it filters), the spool form is
union-of-all (it doesn't).
Rewrote the merge to push each cloud setting_id and each LocalPreset row
as its own FilamentOption with the full @printer suffix preserved in
displayName. Built-in dedup against cloud setting_id is kept (mirrors
ConfigureAmsSlotModal.tsx). Wired api.getBuiltinFilaments() into both
callers. slicer_filament persistence is unchanged so existing spools
keep slicing correctly.
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.
The earlier `min-h-0` fix on the spool list (61314cf2) made the
shrinkable child shrinkable, but on @elit3ge's 838px viewport the
four stacked templates (~310px) plus footer still blew past
max-h-[90vh] once Brave's browser chrome ate into vh, and
overflow-hidden on the modal clipped Avery 5160 mid-row with the
Cancel button entirely below the clipped bottom edge — no scroll
path. The screenshot showed the spool list at ~5 visible rows with
its own scrollbar still active, confirming the templates section's
natural height was the dominant problem, not the spool list.
Templates now render as a responsive grid (grid-cols-1
sm:grid-cols-2 gap-2) so the four buttons pack into a 2x2 grid
above the sm breakpoint, trimming ~150px of vertical. Per-cell
padding tightens to p-2.5, labels/hints get text-sm + truncate,
and the full strings are reachable via title="<label> — <hint>"
on each button. Footer drops py-3 to py-2 for a few extra pixels.
The min-h-0 on the spool list is kept as a belt-and-braces shrink
for any viewport tighter still. Mobile (<sm) keeps the stacked
layout — no regression there.
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.
The SliceModal's preview slice runs against unsliced project files to
discover per-plate AMS slot consumption. Until now it always used
slice_without_profiles — accurate slot mapping (a model property) but
gram numbers were derived from the file's embedded process settings,
which can drift from the triplet the real print will use.
When the caller provides a bundle id + printer/process/filament preset
names, get_preview_filaments now routes through slice_with_bundle so
the preview's gram numbers match what the real print will produce.
Cache key picks up a bundle-context fingerprint so different bundle
picks on the same file occupy distinct entries.
Backend:
- slice_preview.get_preview_filaments: optional bundle_* params
- library.py + archives.py: forward params via /filament-requirements
Frontend (forward-compat for the upcoming SliceModal Bundle tier):
- api.getLibraryFileFilamentRequirements / getArchiveFilamentRequirements
accept an optional 4th-arg bundle context object
Closes the longest-standing inventory gap — finding a specific spool
in a closet of 50 partials. Per-spool icon button on every inventory
card and table row, plus a "Print labels..." header action that opens
a multi-select picker pre-loaded with the currently filtered spools.
Four pre-built templates: AMS holder (30 x 15 mm) for the popular
Makerworld AMS Filament Label Holder, single box label (62 x 29 mm)
for Brother PT/QL or Dymo small labels, Avery L7160 (A4, 21 per
sheet), and Avery 5160 (US Letter, 30 per sheet). Each label carries
the colour swatch (with multi-colour gradient stripes for spools
with extra_colors set), brand, material, name, the *spool ID*
(bsaunder's articulated user-need: telling 8 spools of "PLA White"
apart, especially partials), and a QR code that deep-links to
/inventory?spool=<id> for phone-scan round-trips. Box-label adds
storage location; AMS-holder drops the QR — at 30 x 15 mm there is
no room for swatch + text + QR without truncating away the spool ID,
and AMS-bay identification is at arm's length where the swatch and
ID are enough.
Server-side rendering via ReportLab + qrcode (already a dep). Pure
Python, no headless browser, no system libs. Output is byte-identical
across browsers, Avery sheets align to <0.1 mm, and bulk export is
one click for one PDF. Two endpoints — POST /inventory/labels (local
DB) and POST /spoolman/labels (Spoolman-backed) — gated on
INVENTORY_READ, capped at 500 spools per request, returning
application/pdf via StreamingResponse. The renderer is decoupled
from the SQLAlchemy model via a LabelData dataclass so the same code
path serves both modes.
Modal picker scales to large libraries: search (substring match
across name / brand / #ID), material filter chips derived from the
visible spools, additive Select-all-visible / Deselect-visible /
Clear-all actions so selections survive filter changes. Restyled
twice in development — first cut used generic Tailwind which clashed
with the inventory's bambu-dark palette; second cut switched to
bambu-dark-secondary / bambu-green / bambu-gray to match.
Two render bugs found during visual inspection of generated PDFs and
fixed before commit:
1. AMS-30x15 template originally produced labels with only swatch
+ QR and no text at all — the side-by-side layout left <5 mm
for the text column, so the renderer bailed without drawing
anything. Layout split into tight (h<20mm) and roomy (h>=20mm)
regimes; tight regime drops the QR and gives the right column
to brand + material + a 13pt-bold spool ID.
2. Box-62x29 template aggressively truncated text — swatch + QR
each at ~14 mm on a 26mm-tall label squeezed the text column
to ~16 mm, turning "Polymaker Ivory" into "Polymak..." and
"Polymaker . PLA . Matte" into "Polymaker ...". Swatch capped
at 16 mm, QR capped at 18 mm and constrained to ~20% of width,
leaving the text column ~30 mm — full names render without
truncation.
Both bugs pinned by regression tests in test_label_renderer.py that
render with pageCompression=0 so the resulting PDF bytes contain the
text as ASCII and `assert b"Polymaker" in pdf` works.
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.
The ams_load_filament / ams_unload_filament MQTT primitives existed
in bambu_mqtt.py but were unused — no HTTP route and no UI. Surface
both as POST /printers/{id}/ams/load?tray_id={int} and
POST /printers/{id}/ams/unload, gated on PRINTERS_CONTROL.
Wire them into the existing AMS slot popover (next to "Re-read RFID")
and add a popover wrapper on the external spool slot which had none.
Hidden while the printer is RUNNING, mirroring the RFID re-read
gating. Both buttons enabled when permission is granted; the printer
no-ops gracefully if there's nothing to do (matches BambuStudio).
Dual-extruder H2D Ext-R support is the trickier piece. The existing
ams_load_filament(254) capture came from a single-extruder printer
and used slot_id=254, curr/tar=-1. Captured the Ext-R command from
BambuStudio fresh: it sends ams_id=255, slot_id=0 (the right
extruder index, NOT a slot index), target=255, and curr/tar = the
actual right-nozzle temp (read from state.temperatures["nozzle_2"],
falling back to 215 °C if cold so the printer doesn't reject the
command on a nonsensical temp). Added that as a new branch in
ams_load_filament; the existing tray_id=254 branch is preserved
verbatim — no risk of regression on single-external setups.
@smandon flagged the 40×40 cover thumbnail as too small to recognise
the print and asked for a click-to-enlarge full preview. Enlarging
the thumbnail itself would shift the card grid layout, so keep the
small thumbnail and show a 384×384 hover popover with the full image
in ``object-contain`` rendering (so tall MakerWorld photos aren't
cropped to a square).
Why a portal: ProjectCard carries ``overflow-hidden`` (rounded
corners + color accent bar), so any in-tree popover gets clipped the
moment it extends past the card. Rendering via
``createPortal(..., document.body)`` escapes every ancestor clipping
context, and ``position: fixed`` with measurements from
``getBoundingClientRect()`` keeps the popover pinned next to the
thumbnail regardless of grid position. ``pointer-events-none`` on the
popover so it can't intercept hover and create a flicker loop;
``z-[100]`` so it stacks above sibling cards.
Edge handling: if the thumbnail is near the viewport's right edge the
popover flips to the LEFT side of the thumbnail; vertical position is
clamped so the popover never overflows the window top or bottom. The
thumbnail's own ``onClick`` is ``stopPropagation``'d so hovering the
popover area never accidentally triggers the parent card's
"open project" navigation.
Tests: 2 new ``ProjectsPage.test.tsx`` cases — mouseenter mounts the
popover at document.body level (not nested in the card subtree, which
would re-introduce the clipping bug, and the assertion catches that);
mouseleave unmounts it; the popover img points at the same
cover-image URL as the small thumbnail with ``object-contain``; cards
without a cover_image_filename never mount the portal-rendering
component.
@maugsburger surfaced four bugs against the original #1154 multi-colour
swatch work:
1. Editing an existing spool always opened with the Extra Colours field
blank, even when the COLOR preview banner above it was rendering
correctly from the saved data. ColorSection seeded its local
``extraColorsDraft`` via ``useState(formData.extra_colors)`` at
mount time, but SpoolFormModal opens *before* its own useEffect
populates ``formData`` from the spool record — so by the time the
saved value landed, the input had already locked onto ''. The user
then had to retype the value before saving anything else.
2. Dual Color and Gradient produced the same diagonal blend
(``linear-gradient(135deg, A, B)``), so the two variants were
visually indistinguishable. The whole point of the Dual Color variant
is that the spool has two distinct bars on the reel — a smooth blend
defeats it.
3. Sparkle was almost invisible on card-sized swatches. The original
4-dot pattern (each ~1px) read fine on the inline 20×20 swatch but
disappeared on the 60-pixel inventory card banners — exactly where
the user actually identifies a spool.
4. Checkerboard cell density scaled with the swatch — the same 4-cell
pattern was either tiny squares on a small swatch or four huge
squares on a card-sized banner. The user couldn't tell a translucent
filament from a multi-colour one because the indicator changed shape.
Fix:
- ``ColorSection.tsx``: ref-guarded ``useEffect`` resyncs the draft
whenever the parent's ``formData.extra_colors`` changes via an
external update. ``commitExtraColors`` updates the ref before
calling ``updateField`` so live user typing is round-tripped without
the resync useEffect clobbering it.
- ``filamentSwatchHelpers.ts: buildColorLayer``: branch on
``effect_type``. ``dual-color`` and ``tri-color`` produce
``linear-gradient(to right, c1 0 X%, c2 X% Y%, ...)`` with CSS
double-position stops (hard line, not blend) and equal-width
segments. ``gradient`` keeps the original 135° smooth blend. The
``multicolor`` conic-gradient path is untouched.
- ``filamentSwatchHelpers.ts: EFFECT_OVERLAYS.sparkle``: bumped from 4
dots to 13 flecks in mixed sizes (1 / 1.5 / 2 px) and varying
opacity (0.65 → 1.0) for a depth-of-field "metal flake" feel.
- ``filamentSwatchHelpers.ts: buildFilamentBackground``: now returns
``{ backgroundImage, backgroundSize }`` so per-layer sizes can be
applied — painted layers stay ``cover``, the checkerboard gets a
fixed 12px tile so cell density is constant regardless of element
size. Updated the three existing call sites (``InventoryPage`` group
banner + spool card, ``ColorSection`` preview) to spread the style
object directly. ``FilamentSwatch.tsx`` composes the same per-layer
sizing inline so its output stays in lockstep.
Tests: 8 new frontend cases pinning the four fixes — Dual/Tri Color
hard-split (3 tests + 1 regression guard that Dual ≠ Gradient for the
same stops), Sparkle prominence (≥ 10 distinct radial-gradient layers
in the rendered background), checkerboard density (last backgroundSize
layer is a fixed pixel value, not ``cover``), 4 hydration cases (fills
when formData arrives via parent update, resyncs when the spool
changes mid-form, doesn't clobber live user typing, clears when the
new spool has no extra_colors). Existing buildFilamentBackground tests
updated for the new return-object shape. Full frontend suite: 1610
passed; full backend suite: 3598 passed; no regressions.
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.
Bambuddy already supports running as a Home Assistant addon
(HA_URL/HA_TOKEN env-var integration since #283, community addon at
hobbypunk90/homeassistant-addon-bambuddy), but the update UI was
oblivious to it: HA addon users saw the in-app "Update available"
banner and, on Settings, the docker-compose snippet — neither of
which they can act on, since the HA Supervisor owns the addon
lifecycle.
Detection uses the SUPERVISOR_TOKEN env var that HA Supervisor
injects into every addon container; no other environment sets it,
so the check has zero false-positive surface.
Backend:
- new _is_ha_addon() helper in routes/updates.py
- /updates/check now returns is_ha_addon: bool and extends
update_method to 'git' | 'docker' | 'ha_addon'
- /updates/apply checks HA before Docker (HA addons ARE Docker
containers, so checking docker first would mis-classify) and
returns an HA-specific message that points to Settings →
Add-ons → Bambuddy in HA
- response keeps is_docker: true alongside is_ha_addon: true so
older frontend bundles still hit a managed-deployment branch
instead of rendering an Install button that can't work
Frontend:
- SettingsPage update card branches on is_ha_addon BEFORE
is_docker; HA users get a Supervisor-targeted message instead
of the docker-compose snippet
- Layout update banner is suppressed for HA addons — HA
Supervisor surfaces its own update notification natively, so
Bambuddy's banner would be duplicate noise linking to a page
that just says "update via HA"
- Plain Docker deployments are unaffected
i18n: settings.updateViaHomeAssistant added to all 8 locales with
full native translations.
Tests: 3 backend unit tests for _is_ha_addon (present, absent,
empty-string treated as unset), 3 backend integration tests
(HA-precedes-Docker rejection on apply; HA branch on check; plain
Docker branch on check), 2 SettingsPage tests pinning the
mutually-exclusive UI rendering, 2 Layout tests pinning banner
suppression for HA and retention for plain Docker.
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.
VP-uploaded archives land with status='archived' (uploaded but never
sent to a printer), but the Archives page sidebar only offered
All/Recent/This Week/This Month/Favorites/Failed/Duplicates -- no way
to filter "what's still queued in my library" vs "what's been printed."
Two new collections fill the gap: Not Printed filters to
status==='archived'; Printed filters to any final-status archive
(completed/failed/aborted/cancelled/stopped) so users see every
archive that had a print attempt regardless of outcome (the existing
Failed collection covers just the failure subset).
Frontend-only -- the status field was already populated correctly by
the VP archive paths, this was purely a UI gap. 2 new tests pin the
filter behaviour against a 4-status fixture.
- ContextMenu's capture-phase document.scroll listener was firing on
internal submenu scrolls too, slamming the whole menu shut on any
wheel / arrow-key / scrollbar interaction past the 300px max-height.
Handler now ignores scrolls whose target is inside menuRef so only
page-level scrolls dismiss.
- Sort projects alphabetically (localeCompare) at every project-picker
site: Archives context-menu submenu (x2), BatchProjectModal,
EditArchiveModal, PendingUploadsPanel, FileManagerPage. Native
<select> sites sort once via react-query's select option; custom
button lists sort inline.
- Add filter-by-name search to the Archives "Add to Project" submenus
(new submenuSearchPlaceholder prop on ContextMenuItem) and to
BatchProjectModal. Both gated on >5 projects so small libraries
stay uncluttered. Enter picks the first match.
- New archives.menu.searchProjects i18n key in all 8 locales (en/de
translated; six others seeded with English copies pending native
translation, matching the existing flow).
End-to-end live progress, two correctness fixes, and a UX warning around
the upstream OrcaSlicer bugs we discovered while testing.
LIVE PROGRESS
=============
Wire OrcaSlicer / BambuStudio's --pipe progress channel through the
sidecar -> Bambuddy -> persistent toast so a user-initiated slice shows
"{name} -- Generating G-code (75%) -- 47s" instead of just elapsed time.
The same wiring covers the SliceModal's filament-analysis preview slice
(the real slice that fires before profile picking, used to discover
which AMS slots an unsliced plate consumes) and the embedded-settings
fallback path triggered by Orca's --load-settings segfault on complex
H2D models.
- Sidecar (orca-slicer-api/bambuddy/profile-resolver, separate commit):
switch /slice from execFile to spawn, mkfifo per request, parse the
CLI's structured JSON progress events into a per-process
ProgressStore, expose GET /slice/progress/:requestId.
- Bambuddy backend: slicer_api.slice_with_profiles + slice_without_profiles
accept request_id + on_progress, spawn a 1Hz parallel poller that
forwards each snapshot via SliceDispatchService.set_progress(job_id,
...) onto the matching SliceJob; GET /slice-jobs/:id includes the
latest snapshot on every poll. The 404 from the early-race window
(POST fired before sidecar's progressStore.start) is treated as a
retry rather than terminal -- otherwise the poller bailed before any
progress could ever arrive.
- /api/v1/slicer/preview-progress/:requestId proxies the sidecar's
progress endpoint for the modal's filament-discovery flow (the
/filament-requirements call is server-originated; the browser can't
reach the sidecar directly).
- Frontend: SliceJobTrackerContext re-renders the persistent toast with
the new format when a useful progress frame is present, falls back
to elapsed-time-only when the sidecar hasn't emitted yet or doesn't
support progress. SliceModal.FilamentAnalysisSpinner generates a
per-(source, plate) UUID, polls the proxy at 1Hz, and mirrors the
inline spinner contents into a separate persistent toast so the
preview slice doesn't feel silent either.
CORRECTNESS FIXES
=================
- MakerWorld imports were persisting URL-encoded filenames verbatim
("stormtrooper-helmet%20h2d.3mf"). Backend now urllib.parse.unquote
s the manifest-supplied name and the URL path-tail fallback before
passing to save_3mf_bytes_to_library; frontend defensively
decodeURIComponent s in the slice toast / analysis spinner so
already-imported rows display cleanly without a backfill migration.
- The fallback path's slice_without_profiles call now forwards the
same request_id + on_progress as the primary slice_with_profiles
call so the toast keeps updating across the segfault -> embedded-
settings retry boundary instead of going blank.
ORCASLICER WARNING
==================
Verified two upstream OrcaSlicer CLI bugs reproduce on the latest
nightly (2.4.0-dev, 2026-04-28) with the help of an isolated AppImage
extract and a minimal sentinel-value-injected cube fixture:
- OrcaSlicer/OrcaSlicer#12426 -- SIGSEGV in
update_values_to_printer_extruders_for_multiple_filaments on
painted multi-extruder 3MFs (commented on the existing thread,
not a new issue)
- OrcaSlicer/OrcaSlicer#13386 -- CLI strict-validates parameter
values BambuStudio writes by default (solid_infill_filament: 0,
tree_support_wall_count: -1, prime_tower_brim_width: -1) and
rejects with exit 238, even though Orca's own GUI tolerates
them (filed by us alongside this change)
Settings -> Workflow -> Slicer card renders an amber inline warning
under the preferred-slicer dropdown when orcaslicer is selected,
linking both upstream issues and recommending BambuStudio until the
fixes land. Option stays pickable -- users who only slice STLs aren't
affected by either bug.
Five stacked slice-pipeline bugs that each made the modal's profile picker
theatrical for 3MF inputs:
(1) `_strip_3mf_embedded_settings` removed `model_settings.config` /
`slice_info.config` / `cut_information.xml` along with
`project_settings.config`. The CLI silently exited after
"Initializing StaticPrintConfigs" — exit 0, no result.json — and
Bambuddy masked the failure by re-running with embedded settings
and the source's bound printer. Strip removed from the dispatch
path entirely.
(2) Standard-tier preset stubs lacked the `type` field, so the CLI
rejected `--load-settings` with rc=-5 ("input preset file is
invalid") and the same masking fallback fired. Added
`_SLOT_TO_PROFILE_TYPE` so each stub carries the right
machine/process/filament discriminator.
(3) Sliced-archive cards listed every project-wide AMS slot (16+
swatches for a 2-color print). `slice_and_persist_as_archive` now
reads `filament_type` / `filament_color` from the sliced output's
`slice_info.config` (which `ThreeMFParser` already gates on
`used_g > 0`) instead of inheriting from the source archive.
(4) SliceModal had no warning when the picked printer profile didn't
match the source 3MF — the CLI rejects cross-printer slices
(rc=-16) and fell back to embedded settings, producing wrong-printer
g-code that errored at print dispatch. Plates response now exposes
`source_printer_model`; the modal compares against the picked
profile name and disables Slice + shows an inline warning on
mismatch.
(5) MakerWorld URL-paste resolver listed plate instances without
showing which printer each was sliced for (`/instances/hits`
omits compatibility info that lives on `design.instances[]
.extention.modelInfo`). The resolve route now joins both payloads
by instance ID and forwards `compatibility` + `otherCompatibility`
onto each hit; the MakerWorld page renders "Sliced for {primary}"
+ "Also marked compatible: ..." per row.
Tests: 6 unit tests for `extract_source_printer_model_from_3mf`, 1 for
filtered filament metadata via ThreeMFParser, 2 for makerworld resolve
compat-merge (happy path + missing modelInfo), 3 frontend SliceModal
tests for the printer-mismatch warning + Slice-disabled gate. New i18n
keys `slice.printerMismatch`, `makerworld.slicedFor`,
`makerworld.alsoCompatible` across all 8 locales.