- The Speed and AMS load/unload tests broke after the printer-card
refactor in #1661 (icon-only Gauge button replaced the "100%" badge,
hover-card actions replaced the kebab "Slot options" button). Add
data-testid="speed-control" + data-testid="filament-slot" as stable
test hooks, rewrite both files around them. Parametrize the four-mode
speed-selection test. Update the "RUNNING hides menu" assertion to
"Load/Unload buttons exist but are disabled" — the new UX shows
actions on hover and disables them rather than hiding the trigger.
- Drop `animate-in fade-in-0 zoom-in-95 duration-150` from
FilamentHoverCard and EmptySlotHoverCard. The card briefly painted
at the offscreen (-9999, -9999) coords during the zoom-in transition,
reading as a fly-in from the upper-left corner. With the animation
gone it just appears in place at its computed position.
PR #1661 swapped the visible speed badge ("100%") for an icon-only Gauge
button and replaced the kebab "Slot options" button with hover-card
actions inside FilamentHoverCard. The two existing test files weren't
updated alongside the refactor and stayed broken on dev.
- Add data-testid="speed-control" to the Gauge button and rewrite the
Speed tests around it; assertions on the percentage text are gone
because that text no longer renders. Parametrize the four-mode API
call test.
- Add data-testid="filament-slot" to FilamentHoverCard's trigger
wrapper and rewrite the AMS load/unload tests around
fireEvent.mouseEnter → portaled actions. Replace the "hides menu
while RUNNING" assertion with "Load/Unload buttons exist but are
disabled" — matches the new UX shape.
The Slicer -> Local Profiles page kept showing a just-deleted row for
the ~hundreds of ms it took invalidateQueries to refetch. A quick
re-click on the same row opened a second delete-confirm modal that
resolved to a 404 from the backend.
Add an optimistic queryClient.setQueryData filter in deleteMutation's
onSuccess so the row disappears the instant the DELETE returns 200.
Existing invalidateQueries calls stay in place to reconcile any drift.
Found while reproducing #1713 (verifying maziggy's setup against the
reporter's). Unrelated to that investigation but caught here.
Major version bump for the frontend build:
- vite ^7.3.2 -> ^8.0.16
- @vitejs/plugin-react ^5.1.1 -> ^5.2.0
Vite 8 swaps Rollup for Rolldown as the default bundler
(Rust-backed, same plugin contract). The bump also lifts the
transitive esbuild floor to 0.28.1, closing the last open
advisory in the audit chain.
vite.config.ts surface audited and unchanged:
- defineConfig, Connect type
- serveGcodeViewer configureServer middleware
- server.proxy with WebSocket upgrade for /api/v1/ws
- build.outDir / emptyOutDir / chunkSizeWarningLimit
- resolve.alias for @
- base: '/' regression guard from #1221
vitest@4.1.8 already accepts vite 8 in its peer range
(^6 || ^7 || ^8); no test-runner bump required.
Node floor for vite 8 is ^20.19.0 || >=22.12.0; CI Node 20.x
line satisfies this.
Not taken: plugin-react v6 — it requires
babel-plugin-react-compiler and @rolldown/plugin-babel as
peers and is a separate scope.
The 24h session cap from the M-2 audit finding was hard-coded, so the
"Remember Me" checkbox could only control storage location, never
duration. Add session_max_hours setting (default 24, max 720) honoured
at all four token-issuance sites: plain login, 2FA TOTP/email, 2FA
backup, OIDC.
- backend/app/core/auth.py: SESSION_MAX_HOURS_HARD_CEILING + resolver
that clamps to [1h, 720h] and falls back to 24h on missing/blank/
unparseable. DB errors propagate — the login transaction must abort
on a broken DB rather than silently extend or shrink the lifetime.
- backend/app/api/routes/auth.py, mfa.py: all four sites read the
resolved value instead of ACCESS_TOKEN_EXPIRE_MINUTES directly.
- backend/app/schemas/settings.py, routes/settings.py: schema field
with ge=1 le=720 + int coercion in _build_settings_response.
- frontend/src/pages/SettingsPage.tsx: half-width card at top of
Settings -> Users left column with 24h/7d/30d presets, custom input,
and a yellow warning when value > 24h.
- frontend/src/i18n/locales/*.ts: 8 new keys per locale, real
translations in all 11 (en/de/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW).
- backend/tests/integration/test_session_policy.py: 15 tests across
resolver clamping, login JWT exp end-to-end, settings API round-trip.
Already-issued tokens keep their original expiry; the new setting only
affects future logins.
The SpoolBuddy inventory page reimplemented its filter inline and only
matched material/subtype/brand/color_name/note, while Bambuddy's main
inventory uses the shared filterSpoolsByQuery helper which also matches
spool ID, slicer_filament_name, and storage_location. Delegate to the
shared helper so both pages stay in lockstep.
- frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx: replace
inline filter with filterSpoolsByQuery
- frontend/src/__tests__/pages/SpoolBuddyInventorySearch.test.ts: lock
in ID / partial ID / pre-fix fields / parity-gain fields
Restructures the queue page around three tabs (Queue / History / Timeline)
and adds first-class batch grouping plus a real time-based timeline.
Queue tab
- Layout toggle: Sort by Position (flat list) or Group by Printer (per-
printer section cards with aggregate count / time / weight headers).
- Batch grouping: pending items sharing a batch_id render as a single
collapsible row with aggregate stats; children draggable within the
batch only. Per-batch collapse state in localStorage.
- Multi-drag: dragging any selected row moves all selected items as a
contiguous block via DragOverlay (+N ghost).
- Selection bar gains a Group as batch action when 2+ ungrouped items
are selected. Ungroup lives on the batch parent row.
History tab
- Two-line rich rows: filament color swatch + weight + type, user
attribution, inline error message on failed / skipped rows.
- Responsive 1 / 2 / 3 column grid so a long history uses available
width instead of stretching one row per line.
- Batch siblings group into a collapsible parent with status-rollup
chips (3 OK / 1 failed / etc).
- Thumbnail hover preview shows the full image at 192x192 next to the
small thumb.
Timeline tab
- Replaces the hourly-list view with a Gantt swimlane: one row per
printer (plus per target_model and unassigned), horizontal hour
axis, jobs as bars positioned by start time and sized by duration.
- Live NOW marker.
- Only committed schedules are rendered: currently printing items,
pending items with scheduled_time, and pending ASAP behind an active
print. Staged (manual_start), waiting (waiting_reason), and ASAP
jobs on idle printers are filtered out.
- 24h rolling window with 12h step controls.
- Per-bar tooltip with start, end, progress, batch name.
Backend
- POST /queue/batches creates a batch, optionally assigning existing
pending item_ids (manual grouping) or returning an empty batch the
client can attach to subsequent /queue/ POSTs.
- POST /queue/batches/{id}/ungroup clears batch_id from all members
(skipping items the caller does not own) and deletes the batch row
when no members remain.
- POST /queue/ accepts an optional batch_id and validates that the
batch exists, is active, and the caller may modify it. The existing
quantity > 1 auto-batch path still fires when no batch_id is sent.
PrintModal
- When N plates from one source are queued in a single submission
(model assignment or single printer), the modal pre-creates a batch
and passes its id to each addToQueue call so multi-plate jobs land
grouped automatically. Falls back to ungrouped items if the batch
pre-create fails.
navPermissions in Layout.tsx gated three resources on the LEGACY *:read flag.
Default Operators group is seeded with *_own only (and the migration map flips
legacy → _own on existing groups), so non-admin users never held the legacy
permission and the sidebar hid Archives / Queue / Files even though the
underlying API accepted their requests. Reporter only spotted Files; same bug
shape applied to Archives and Queue.
Fix: navPermissions accepts Permission | Permission[]; the three affected
resources list all three tiers. isHidden checks .some(hasPermission) for
arrays. Permission type extended with the matching *_own / *_all variants —
backend already shipped them, the TS type just didn't declare them.
ProtectedRoute and PermissionRoute now pass the requested location as
router state when redirecting to /login. LoginPage stashes it in
sessionStorage before the OIDC provider redirect (since window.location
kills React state) and consumes it on all three post-login navigations
(credentials, 2FA, OIDC token exchange). Targets are sanitized to
same-origin internal paths only — protocol-relative and /login itself
are rejected to prevent open-redirect.
QR labels (https://host/inventory?spool=N) now land on the scanned
spool instead of the printer page after authentik / any OIDC SSO login.
VP queue-mode multi-plate Send All
==========================================
BambuStudio / OrcaSlicer "Send All" of a multi-plate project uploads ONE
3MF containing every plate (one FTP STOR, single filename) — slice_info.config
inside the file lists N <plate> blocks with their own index metadata and
their own Metadata/plate_N.gcode payload. Pre-#1733 the VP queue path
called _extract_plate_id which returned only the FIRST plate index, and
_add_to_print_queue built exactly one PrintQueueItem from it. Plates 2..N
silently dropped on the floor. From the user's perspective: Send All of a
3-plate project produced 1 queue item, indistinguishable from a regular
single-plate Send, with no log line to explain the discrepancy.
The wire was confirmed against the live H2D-1 Proxy VP: the same file
ships whether the user clicked Send or Send All; the only intent signal
is the count of <plate> blocks inside slice_info.config.
Fix: replaced _extract_plate_id (-> int | None) with _extract_plate_ids
(-> list[int]). The list contains every <plate> block's index in order;
falls back to [1] when slice_info.config is missing / unparseable so the
single-plate case is preserved. _add_to_print_queue now loops over the
list and creates one PrintQueueItem per plate, with:
- plate-specific position = MAX(position) + iteration_number, so the
items inherit consecutive positions and the slicer's plate order
becomes the queue execution order.
- per-plate required_filament_types / filament_overrides via
extract_filament_requirements(file_path, plate_id) — the plate-aware
filter shipped with #1697 — so the scheduler's per-printer "Any X"
matching dispatches each plate onto a printer with the right
colours loaded for THAT plate, not for plate 1's filament set.
- shared archive_id across all plates (one upload = one archive row).
- the VP's auto_dispatch + manual_start posture inherited unchanged.
Net behaviour: single-plate Send hits the loop once → exactly today's
result (one queue item, plate_id from the slicer, one archive). Multi-
plate Send All of a 3-plate file → 3 queue items, plate_id 1/2/3,
consecutive positions, all referencing the same backing archive.
Archive delete cascades to queue rows
=============================================
Previously the soft-delete path (the default the trash-can button uses)
called _cancel_pending_queue_items which only flipped queue rows with
status='pending' to status='cancelled' while leaving every other status
alone AND leaving every row in the DB. The Send All multi-plate work
above made this much more visible: deleting an archive backed by N
queue items now had to clean up N rows, and what users saw instead was
N "cancelled" rows lingering in the queue history.
Backend:
- Replaced _cancel_pending_queue_items with _delete_related_queue_items
(db, archive_id) -> int. DELETEs every queue row where
archive_id = X regardless of status. Matches what the hard-delete
path already did via the ON DELETE CASCADE FK on
print_queue.archive_id — both paths now produce the same end state.
- Print history lives in PrintLogEntry (FK ON DELETE SET NULL) and is
untouched; Quick Stats / accuracy bands are preserved across both
delete paths.
- 409 guard on archives.py::delete_archive when any related queue
item is currently status='printing'. Both soft and hard delete are
gated; deleting the archive while a print is live would strip the
dispatcher's metadata trail (filament / plate / ams_mapping) out
from under the running print.
- New GET /archives/{id}/delete-impact endpoint returns
{related_queue_items: N, currently_printing: M}. Cheap, single
endpoint, deliberately NOT folded into the archive list response
so the much larger list endpoint isn't forced to run the same
query per row.
Frontend:
- ArchivesPage delete-confirm modal queries the new endpoint when the
modal opens (useQuery with enabled: showDeleteConfirm) and renders
an amber "N queue items linked to this archive will also be removed"
line when total > 0 AND printing = 0, OR a red "Cannot delete —
M queue items are currently printing" line when printing > 0
(confirm button disabled in that case so the user can't bonk the
409 on submit).
- ConfirmModal gained an optional confirmDisabled?: boolean prop —
isLoading was the only disable knob before; this adds the external-
precondition path.
- 2 new i18n keys (deleteQueueItemsWarning, deleteBlockedByPrinting)
translated across all 11 locales per feedback_translate_dont_fallback —
no English fallbacks.
No DB migration — the CASCADE FK was already in place; only the helper's
semantics changed.
The per-archive Print Log table cell at ArchivesPage.tsx:3882 rendered
filament_color as a single swatch with
`backgroundColor: entry.filament_color.startsWith('#') ? ... : undefined`.
For multi-color prints, the backend writes filament_color as a comma-joined
string ("#FFFFFF,#000000,#FF0000"). The whole string trivially passed the
startsWith('#') check but isn't a valid CSS color — the browser silently
drops the declaration and the swatch falls back to its black/20% border,
which on the dark theme reads as a barely-visible grey dot. Reporter's
screenshots showed "PLA" text with no visible swatch at all. DB column
was correct; render dropped the colors.
The Archive Card view at ArchivesPage.tsx:1072-1083 and :2114-2125 already
splits on comma and renders one swatch per color — only the Print Log
table cell had been missed when multi-color support landed elsewhere.
Fix mirrors the card pattern: wrap swatches in a flex container, split
on comma, trim, render one w-3 h-3 swatch per color with
backgroundColor and title={trimmed}. Single-color prints render one
swatch (no behaviour change). Empty / non-hex entries fall through to
no backgroundColor rather than poisoning the CSS for siblings.
Does NOT cover the reporter's second symptom — new multi-color prints
missing from filament usage history. That's usage_tracker._track_from_3mf
and the slot-to-tray mapping chain; needs a support bundle (PRINT START
+ PRINT COMPLETE [UsageTracker] log lines + captured ams_mapping) before
shape can be confirmed. Tracking as #1731 part 2.
The Filament Profile picker in the Configure AMS Slot modal listed
profiles for every printer the user had ever imported / cloud-synced.
On H2D the reporter saw local "Custom" PETG/PLA for A1 mini and P1S
alongside Bambu Cloud and Orca Cloud profiles named "X1C eSUN PETG-
Basic Filament" - none of them usable on the slot they were configuring.
Three filter gaps in the same picker:
- Local "Custom" imported profiles were unconditionally listed. Now
parse their compatible_printers JSON and run presetCompatibility()
against the slot's full slicer preset name ("Bambu Lab H2D 0.4
nozzle") derived from the printer-model registry + slot nozzle.
Hide on 'mismatch'; 'match' and 'unknown' keep showing (back-compat
for hand-edited imports without compatible_printers).
- Cloud presets with the "@Bambu Lab <long-name>" suffix form (user-
renamed Bambu Cloud presets, most Orca Cloud profiles) slipped
through the existing "@BBL <code>" matcher. extractPresetModel now
handles both, with case-insensitive registry reverse-lookup so
"A1 mini" vs "A1 Mini" capitalisation drift doesn't hide A1 Mini
profiles (#1649 alias match preserved).
- Cloud presets with the model in the BODY of the name and no @ suffix
("X1C eSUN PETG-Basic Filament") returned null from the extractor.
Added a body-text scan against every known model token from the
registry, long-first sort so "A1 Mini" / "X1 Carbon" / "H2D Pro"
aren't eaten by their shorter siblings, word-boundary regex so
"PA1" doesn't false-match "A1".
Fail-open posture preserved: registry not loaded or printerModel empty
no-ops every filter; saved preset bypass keeps the active selection
visible; built-in filaments stay unfiltered (generic fallback); free-
form names with no recognisable token still show.
Before this change, the Re-print and Schedule modals' per-filament
slot dropdown hid every slot whose extruder didn't match the filament's
slicer-assigned nozzle. On H2D with AMS A+C on the left and B on the
right, an L-assigned filament could only pick A or C and an R-assigned
one could only pick B — locking the user out of cross-extruder picks
even when they'd intentionally loaded the required filament into the
other AMS.
The slicer wasn't the source of the asymmetry: BambuStudio Desktop,
OrcaSlicer Desktop, and the Bambuddy sidecar all produced identical
filament_map values for the same source 3MF. Bambuddy's UI filter was.
Drop the f.extruderId === item.nozzle_id clause in FilamentMapping's
loadedFilaments filter. Single-nozzle and FTS short-circuits stay; the
L/R row badge stays as a hint to the slicer's intent. Printer firmware
decides at start-print whether the cross-extruder ams_mapping is valid.
slice_and_persist writes a .gcode.3mf ZIP container but persisted the row
with file_type="gcode". The G-code preview endpoint short-circuits on
file_type == "gcode" and returns the bytes as text/plain, so the embedded
viewer received the raw ZIP body instead of the embedded toolpath.
- Persist file_type="gcode.3mf" on sliced rows (matches _classify_file_type
and external-scan rows).
- get_gcode also routes to the unzip branch when the filename ends with
.gcode.3mf, so rows already written under the bug self-heal on first
preview without a DB migration.
- Extend FileManagerPage badge + viewer-eye gate and ProjectDetailPage badge
to accept "gcode.3mf"; isSlicedFilename / isSliceableFilename already do.
- Add test_library_get_gcode_recovers_legacy_gcode_type_for_3mf: legacy
row preview must be text/plain, contain G28, and NOT start with PK.
Bundle import never delivered what it implied: BambuStudio's .bbscfg export
strips system processes/filaments, so importing a bundle left users without
process presets and slicing fell back to embedded settings on STL. Bundle
mode also hid the standard tier behind a constrained dropdown, the actual
trap reported here.
Removed end-to-end:
- backend: POST/GET/DELETE /slicer/bundles*, SliceRequest.bundle,
SliceBundleSpec, dispatch fork in library.py, bundle-context params on
the filament-requirements endpoints, bundle-fingerprint cache key in
slice_preview.py, SlicerApiService.{import,list,get,delete}_bundle and
slice_with_bundle, BundleSummary / BundleNotFoundError.
- frontend: BundlePicker + BundleStringDropdown, isBundleMode + every
branch, bundle state/queries/dispatch in SliceModal.tsx, SlicerBundle /
SliceBundleSpec types, three bundle API methods. buildCompatibilityIndex
loses its bundle path; presetCompatibility keeps compatible_printers
plus the @BBL fallback.
- SlicerBundlesPanel turns into a permanent static notice explaining the
removal, alternative import paths, and the new slice-time lookup order
(Imported > Orca Cloud > Bambu Cloud > Standard sidecar fallback).
- i18n: slicerBundlesRemoved.{title,description,alternatives,lookupOrder}
translated across all 11 locales; slice.bundle*, slicerBundles.* keys
removed.
Fixed (surfaced by removing bundle mode):
- _resolve_cloud and _resolve_orca_cloud now force type per slot and pin
from: "system" on the payload before json.dumps. Bambu Cloud ships
type as "printer"/"print" and routinely empty `from`; the BS CLI's
--load-settings parser rejects both with return -5 / "input preset
file invalid". Standard tier already did this; cloud paths now match.
Round 2 fixed the model-mode FilamentOverride: tray_info_idx →
sub-brand, plus a material-disambiguated colour name from a new
/inventory/colors/by-material endpoint. The printer-mode panel that
renders the same 3MF (FilamentMapping) was reading the same raw
fields — item.type for the required label, getColorName(item.color)
for the swatch tooltip — and was not touched, so picking "Specific
Printer" still showed "Required: PLA - Black" for a slice the
"Any H2D" branch already labelled "Bambu PLA Matte - Charcoal".
Extract the three-query resolution machinery from FilamentOverride
into a shared hook useFilamentLabels (returns positional
{resolvedName, colorLabel} per slot). Both panels call it; both
read the same labels. The hook also owns extractMaterialHint so the
"strip leading brand token" rule has one source of truth.
FilamentMapping required-side now reads {resolvedName} instead of
{item.type}; swatch tooltip reads `Required: {resolvedName} -
{colorLabel}` instead of `Required: {item.type} -
getColorName(item.color)`.
The Print Queue's schedule dialog hid the per-slot "Force color match"
checkbox when a specific printer was picked, even though the scheduler
in print_scheduler.py:535 honours force_color_match regardless of how
the queue item was created. Model-mode ("Any A1") rendered
FilamentOverride which carries the checkbox; printer-mode rendered
FilamentMapping which had no force-match UI at all. Pure UI gap.
Extend FilamentMapping to accept optional forceColorMatch +
onForceColorMatchChange props, mirroring FilamentOverride's signature,
and render the same <Palette>-iconed checkbox under each filament row
when a handler is wired. PrintModal/index.tsx passes the existing
forceColorMatch state through — same object both modes write into so
toggling between modes preserves the user's selection. No new i18n
keys (printModal.forceColorMatch already ships in all 11 locales).
The shape-of-payload dump shipped earlier rules out cache wipes —
shaddowlink's round-1 captures show AMS data reaches the slicer
byte-identical to what the printer sent. The remaining symptom
(picking a generic filament in archive mode "unloads" the slot) lives
on the command path, which the snapshot dump doesn't see: it writes
only the cached _latest_print_state and the periodic 1Hz push.
Add append_event() in _debug.py — same env flag, separate file at
<log_dir>/vp_wire/<vp>_cmd.jsonl. One JSONL line per event with UTC
iso timestamp, direction (slicer_to_bridge / printer_to_slicer), MQTT
topic, <channel>.<command> grep handle, and parsed payload. Wired at
two points: mqtt_server._handle_publish for slicer publishes (after
JSON decode so the trace matches what the bridge actually parsed) and
mqtt_bridge._on_printer_raw "everything else" branch for printer
responses (after serial rewrite so the trace matches what the slicer
sees on the wire). Pushall / get_version stay out — both are handled
locally and never round-trip through the bridge.
Bytes payloads get the same \x00-tolerance fix from #927 so
OrcaSlicer's C-string-null publishes parse cleanly; un-parseable
bytes fall back to {"raw": "..."} so every line stays valid JSON.
Four #1712 issues from the 2026-06-04 Orca Cloud integration:
(1) Tier order put Orca Cloud above everything across SliceModal,
auto-pick scoring, dropdown groups, the AMS slot picker, and the
backend precedence. Bambu-Cloud-only users saw their profiles
deprioritised behind an empty Orca tier.
(2) Cross-tier dedup hid a same-named preset in all but the highest-
priority tier. A user with both a local-imported and an Orca-synced
"Bambu PLA Basic" couldn't see the Orca copy as a picker option.
(3) CloudStatusBanner nagged signed-out users with a permanent
"Sign in to Orca Cloud" line at the top of every slice -- even after
explicit logout. Bambu Cloud had the symmetric problem.
(4) ConfigureAmsSlotModal source badges were inconsistent: Orca rows
showed only "Custom" (no source identity), Bambu Cloud built-in rows
had no badge at all, and the orthogonal isUser-driven "Custom" badge
collided with the source badge for cloud user presets.
Order is local > orca_cloud > cloud > standard everywhere it lives
(SliceModal SLICE_MODAL_TIER_ORDER + TIER_BONUS + dropdown tier list,
ConfigureAmsSlotModal sourceOrder, and backend precedence). The order
drives auto-pick + visual group rendering; it does NOT hide profiles.
_dedupe_by_name replaced with _enrich_cloud_metadata: every tier
returns its full list across all three slots (printer / process /
filament). The function still backfills Bambu Cloud filament metadata
from same-named local / orca_cloud / standard entries so cloud
filaments score in pickFilamentForSlot.
CloudStatusBanner silently no-ops on not_authenticated for both clouds;
expired / unreachable still surface. The not_authenticated i18n keys
stay in the locale files dormant.
ConfigureAmsSlotModal: one source badge per row, one colour per source
(green Local / purple Orca Cloud / bambu-blue Bambu Cloud / amber
Built-in). The legacy isUser-driven "Custom" badge is gone; every row
identifies its tier consistently.
Drop the off-white background on the A2L marketing render so it composites
cleanly on the dark theme (every other printer image in public/img/printers/
is RGBA with transparent corners; A2L shipped as opaque #F7F7F7). Resize to
320x320 to match the rest of the artwork.
Also wire A2L into getPrinterImage so the printer card actually shows the new
artwork -- without this the resolver fell through to default.png for both
the A2L display name and the N9 internal SSDP code.
- frontend/public/img/printers/a2l.png: new, 320x320 RGBA, transparent
- frontend/src/utils/printer.ts: A2L / N9 -> a2l.png, placed above the a1mini
branch
- frontend/src/__tests__/utils/printer.test.ts: 4 cases mirroring the X2D
shape -- display name, case-insensitive variants, N9 internal code,
regression guard against accidentally matching A2M / A1 / A1 Mini
Internal code N9 (from BambuStudio resources/profiles/BBL/machine/Bambu Lab A2L.json),
serial prefix 26A19 (5-char, same shape as H2C's late 31B8B). Capabilities from
Bambu's official A2L specs page: linear rail, single FDM extruder + integrated
cutter/plotter, no Ethernet (2.4 GHz Wi-Fi only), low-rate chamber camera on
port 6000.
The BambuStudio profile's use_double_extruder_default_texture: true flag
describes two TOOL HEADS (FDM + cutter), not dual filament extrusion — A2L
must NOT be classified as dual-nozzle or AMS routing will target the deputy
slot and firmware rejects with 07FF_8012.
Registry updates: printer_models.py, firmware_check.py, virtual_printer/manager.py,
virtual_printer/mqtt_server.py, PrintersPage.tsx, SpoolBuddyAmsPage.tsx.
12 new test cases in TestA2LModel pin every dimension.
The Stats page's Failure Analysis widget and the per-archive run
sub-table rendered the raw PrintLogEntry.failure_reason value
without translating, so the camelCase keys saved by the new
Print Log row editor (#1687 part 4) surfaced as literal
"filamentRunout" / "cloggedNozzle" text. The Print Log table did
translate the value, so the inconsistency was visible from one
surface to the next.
EditArchiveModal was also still saving the localised label as the
column value while the new editor saved the key - same column,
two formats, two failure modes (group fragmentation on language
switch, new PATCH validation rejection on round-trip).
Three surfaces fixed in one drop:
1. StatsPage.tsx and PrintLogTable.tsx wrap the value in
t('editArchive.failureReasons.${reason}', { defaultValue: reason }) -
the defaultValue path keeps legacy translated-text rows rendering
unchanged.
2. EditArchiveModal stores the camelCase key on save and reverse-
looks up any legacy translated-text value against the current
locale on open. Every save thereafter converts that row forward
to the key format, so the column self-heals over time.
3. Added htmlFor/id to the failure-reason label/select pair (a11y
plus testability).
Configure Slot dropped to "default 0.020" on reopen for slots that were
physically loaded but unconfigured (tray_type="", no slot_preset_mappings
row). The #1689 cali_idx safety net was unreachable from that path —
matchingKProfiles early-returned [] on !selectedPresetInfo before the
safety net ran.
Split the early return so the cali_idx fallback survives the no-preset
case: when selectedPresetInfo is null but slotInfo.caliIdx > 0, return
the active profile as a single-item list (extruder-matched when known).
Strictly additive; caliIdx == 0/null still returns [], existing matcher
unchanged when a preset is resolvable.
Two complementary surfaces for the most-missed install step ("Store sent
files on external storage"):
1. Connection diagnostic check (printer-side variant)
- Reads state.store_to_sdcard, parsed from MQTT home_flag bit 11.
- Pass / fail / skip; instant, no I/O.
- Catches the newer-firmware variant where the toggle moved onto the
printer itself (P2S 01.02 / Studio 2.6+).
An FTP upload-and-verify probe was tried first and rejected. /cache
is always writable from Bambuddy regardless of the slicer setting;
only BambuStudio's own behaviour changes when the toggle flips.
Empirically confirmed against X1C + H2D with the slicer option
toggled off: probe still succeeded, home_flag bit 11 stayed True.
2. Archives-page banner (slicer-side variant)
- The slicer-side toggle is invisible to the printer — older
BambuStudio doesn't push the change to the printer. The diagnostic
can't see it.
- Symptom is deterministic: archiver creates rows with
extra_data.no_3mf_available=True (main.py:2770) when it can't pull
the 3MF from /cache after a slicer-initiated print.
- New endpoint GET /archives/no-3mf-warning returns whether any
archive in the last 30 days has the flag (excluding soft-deleted).
- Amber dismissible banner at the top of /archives; one-shot
localStorage dismissal (matches Layout.tsx update-banner pattern,
but persistent across sessions).
- React-Query disabled after dismissal so the endpoint isn't polled
once the user has been told.
Detects the printer-side variant of install step 4 — many users (esp. on
clean installs) forget to enable this and only notice when their archive
cards have no thumbnails. The diagnostic now catches it upfront.
Detection: read state.store_to_sdcard, which Bambuddy already parses from
MQTT push_status home_flag bit 11 (bambu_mqtt.py:153). Instant, no I/O.
An FTP upload-and-verify probe was tried first and rejected. /cache is
always writable from Bambuddy regardless of the slicer setting — only
BambuStudio's own behaviour changes when the toggle flips, not the
printer's acceptance policy. Confirmed empirically against X1C + H2D
with the slicer option toggled off: probe succeeded, home_flag bit 11
stayed True. So the only reliable signal is what the printer actually
reports about its own state.
Limitation: the printer-side variant only exists on newer firmware
(P2S 01.02 / Bambu Studio 2.6+). On older versions the toggle lives
only in the slicer and the printer never hears about it, so this check
will pass even when the user is missing step 4 in BambuStudio. The
skip-text and the wiki call this out explicitly. A reactive banner on
the no-3MF archive-fallback path is planned as a follow-up to cover
that case.
Statuses:
- pass: state.store_to_sdcard is True
- fail: state.store_to_sdcard is False (-> overall escalates to problems)
- skip: no live state, disconnected, or field never populated
(#1687 part 4, reported by @IndividualGhost1905)
Reporter clarified after part 1 shipped that point 2 wasn't about
archive `tags` (which describe the model — home decor, toys), but
about failure-cause classification on the *log* row itself:
spaghetti, jam, bed-adhesion, etc. Different surface, different
lifetime.
The data field he wanted already existed. PrintLogEntry.failure_reason
is a String(100); the Failure Analysis widget already groups by it;
the Archive Edit modal already mirrors archive.failure_reason into
the most recent log entry (archives.py:1421, shipped with #1444).
The only gaps were:
1. The GET endpoint silently dropped failure_reason (and archive_id
and created_by_id) from PrintLogEntrySchema construction even
when set in the DB — so the Print Log table couldn't render what
the Failure Analysis widget grouped by. Fixed independently of
the editor; regression test added.
2. Orphan log entries (no archive — dispatch errors, aborts before
archive creation, manual entries) had no edit path at all because
the Archive Edit modal cannot reach them. The new endpoint is
the only way to classify those rows.
Changes:
- Backend: new PATCH /print-log/{entry_id} taking
{failure_reason, status}, gated on require_ownership_permission(
ARCHIVES_UPDATE_ALL, ARCHIVES_UPDATE_OWN) — same ownership shape
as the per-row DELETE. Validates against the same 11-key failure
vocabulary and 5-key status set the Archive Edit modal uses;
unknown values return 400 rather than getting stored as raw text
(the i18n layer maps the value back through the vocabulary,
unrecognised values would render as literal strings).
Empty-string failure_reason stores back as NULL so the column's
nullable=True intent is preserved end-to-end. GET endpoint now
surfaces failure_reason, archive_id, created_by_id.
- Frontend: FAILURE_REASON_KEYS moved to an export from
EditArchiveModal.tsx so the new editor reuses the exact same
vocabulary — backend and frontend stay in lockstep. Pencil icon
beside the existing trash icon on every Print Log row, opens a
compact two-field modal (status + failure reason). Save
invalidates print-log and archives-stats query keys so the
Failure Analysis widget reflects the re-classification on the
same response cycle. Failure reason rendered as a sub-label under
the status badge, matching PrintLogTable.tsx's convention.
- i18n: 10 new keys (editEntryTitle, editEntryDescription,
entryUpdated, entryUpdateFailed, archives.permission.noEdit, plus
a 5-key statuses block) translated across all 11 locales. No
English fallbacks.
- Wiki: features/print-log.md gains per-row actions section,
updated permissions table, PATCH/single-DELETE endpoint docs.
When the user clicked Print Anyway on a filament-deficit warning, the
acknowledgement was one-shot. The route cleared manual_start and
filament_short, then the next scheduler tick re-ran
compute_deficit_for_queue_item against identical spool state, found
the same deficit, and re-set both flags. The item bounced between
"user said anyway" and "scheduler re-blocked" — every Play click
returned 409, every confirm got rolled back on the next tick.
Add a persistent acknowledgement flag on the queue item:
- New column `skip_filament_check` on print_queue. SQLite + Postgres
migration branched on is_sqlite() so Postgres doesn't reject
DEFAULT 0 on BOOLEAN.
- PrintQueueItemCreate + PrintQueueItemResponse schemas + the
TypeScript types carry the field.
- POST /print-queue/{id}/start with skip_filament_check=true now
ALSO sets item.skip_filament_check = True (not just clearing
manual_start / filament_short).
- PrintScheduler._block_on_filament_deficit short-circuits to
False — no compute, no flag-setting, no notification — when
item.skip_filament_check is True. We trust the operator's
decision and stop fighting them.
- PrintModal at queue-creation time threads
skip_filament_check=true into the create payload when the user
clicks Print Anyway on the frontend deficit warning, so a print
that was warned-then-acknowledged at add-to-queue time goes in
pre-acknowledged — scheduler never blocks it on first tick.
Flag is not auto-cleared on spool swap by design: if remaining is
now sufficient, the check returns no deficit anyway, so the flag
is moot. Auto-clearing would add lifecycle complexity without
changing behaviour.
AMS Backup awareness (the other half of the discussion) intentionally
NOT included — verified the H2D's bit-26 of print.cfg toggles with
the printer-side AMS Backup setting, but the X1C's cfg has a
different shape entirely and verifying every model family isn't
realistic. Silently under-warning would be worse than always
per-slot. The check stays single-slot for now.
Two related bugs in K-profile matching, same root cause.
#1688 — spool form's PA-profile suggester (PAProfileSection via
isMatchingCalibration in spool-form/utils.ts) matched K-profiles by
parsing the profile NAME for material/brand/variant. Spools already
store slicer_filament (the slicer preset id) and K-profiles already
carry filament_id, but both were ignored — so a user's custom
K-profile whose name doesn't agree with the slicer preset got silently
dropped from suggestions even when the underlying filament_id was
identical.
#1689 — ConfigureAmsSlotModal's matchingKProfiles ran the same
name-only logic on the slot's selected preset. A spool assigned under
"Generic PLA" with a custom K-profile actively bound on the printer
landed in the modal as "K profile not assigned, default 0.020 will
be used", while the printer-card hover-card correctly showed the
active profile. Two paths, only one was filtering by name.
Shared root: spool preset ids and K-profile filament_ids look
different but are equivalent after normalising. Spools store
slicer_filament as the cloud setting_id form ("GFSG98_09" — _09 is
the variant suffix, the S infix marks setting_id form); K-profiles
store filament_id as the bare form ("GFG98"). Plain === doesn't
work; both need normalising. This conversion already existed in the
other direction at buildFilamentOptions (filament_id → "GFS" +
filament_id.slice(2)), so the inverse toFilamentId helper is just
the matching reverse, not new ground.
Fix — one shared helper, two surfaces:
- spool-form/utils.ts: new exports toFilamentId(id) (drops "_NN"
variant suffix and strips the "S" in "GFS", so GFSG98_09 → GFG98)
and isGenericFilamentId(id) (flags Bambu's generic GFx99 ids
which are shared across many filaments and must NOT id-match —
the name fallback handles those correctly).
- isMatchingCalibration: gains slicer_filament?: string in formData,
tries id-match (with generic exclusion) before the existing name
parse. PAProfileSection already passes the full formData so no
caller edit needed. Strictly additive precedence.
- ConfigureAmsSlotModal.selectedPresetInfo: resolves a filamentId
field (toFilamentId(cp.setting_id) for cloud presets,
toFilamentId(builtinFilamentId) for builtin; empty for local /
orca paths which fall through to name match).
- ConfigureAmsSlotModal.matchingKProfiles: id-match check at the top
of the per-profile predicate (preferred when both sides agree
after normalisation), then the existing name-parse logic, then
ALWAYS unshifts the slot's currently-active K-profile by
slot_id === slotInfo.caliIdx — gated on activeIdx > 0 (so caliIdx
0/null doesn't leak unrelated profiles in), extruder-matched when
slotInfo.extruderId is known. This is Spionkiller01's #1689 patch
verbatim with the activeIdx > 0 guard added.
SpoolBuddy: both kiosk K-profile surfaces reuse the shared
components. SpoolBuddyWriteTagPage renders PAProfileSection;
SpoolBuddyAmsPage renders ConfigureAmsSlotModal. Verified — fixes
propagate automatically, no kiosk-specific edits.
What this does NOT change: spools without slicer_filament, K-profiles
without filament_id, and generic GFx99 ids all fall through to the
existing name-based matching path. Strictly additive precedence; no
input shape that matched under the old logic fails to match under the
new. The #1053 cloud-preset PFUS* path is preserved because the
toFilamentId regex /^GFS/ doesn't match a "PFU" prefix.
When the JWT expired on an open tab, the next API request hit a 401 with
"Token has expired"; client.ts cleared the token from storage but
AuthContext.user stayed populated from the original mount. ProtectedRoute
only redirects when user === null, so the protected tree kept rendering
and every subsequent request silently failed with no Authorization
header — the UI looked like every list was empty until a manual refresh
remounted AuthProvider.
The 3 other setAuthToken(null) sites live inside AuthContext itself and
already pair with setUser(null), so only the client.ts cross-module
site needed a React-tree signal.
- client.ts: after setAuthToken(null) on a token-invalidating 401,
window.dispatchEvent(new CustomEvent('auth:expired')). Guarded on
`typeof window !== 'undefined'` for SSR / test safety. Generic
"Authentication required" 401s still don't clear the token or fire
the event — treated as transient timing issues per the pre-existing
comment at client.ts:155.
- AuthContext.tsx: mount useEffect adds a window listener that calls
setUser(null) under the mountedRef guard; cleanup removes the
listener so unmount → remount doesn't double-bind.
Mirrors the patch the reporter shipped on their fork (deec96d1).
Reporter on a 3-AMS P1S saw slots labelled "Empty" even though spools
were physically loaded - OrcaSlicer's Device view showed the same
slots as loaded.
Root cause: the compact label below the AMS slot circle rendered
tray.tray_type || t('ams.slotEmpty'), falling back to "Empty" whenever
the printer firmware hadn't been told which material is in the slot.
getEmptySlotKind already distinguishes 'physical' (firmware confirmed
empty via state 9/10) from 'reset' (tray_type absent but firmware
hasn't confirmed empty - spool loaded, just unassigned). The hover
card and circle border already used that distinction; the compact
label did not.
Fix: label branches on emptyKind - 'physical' keeps "Empty", 'reset'
shows "?" matching the slicer's own convention. External / VT tray
label is unchanged (external trays have no "configured/unconfigured"
distinction - they're either loaded or not). SpoolBuddy AmsUnitCard
carried the same bug and got the same fix plus a tooltip.
Reporter noted the existing "Also remove this print from Quick Stats"
toggle at archive delete is one-shot: if you kept stats then, there was
no later way to drop the row; and rows without a backing archive
(errors, aborts, manual entries) had no delete affordance at all.
Backend: DELETE /print-log/{entry_id} mirrors delete_archive's
ownership flow via require_ownership_permission(ARCHIVES_DELETE_ALL,
ARCHIVES_DELETE_OWN). Owners drop their own rows; admins drop any row;
missing IDs return 404 rather than 200-silently. /archives/stats
aggregates over PrintLogEntry, so the filament / time / cost / count
contribution drops out of Quick Stats in the same response cycle. The
linked archive (if any) is untouched - the log row is a sibling, not a
child.
Frontend: trash icon next to the filament cell on every row, gated on
the same permission shape as the archive trash. Confirm modal -> row
gone. Mutation invalidates both print-log and archives-stats query
keys so the totals re-render without a manual refresh.
#1687 also asks for per-row tagging (already covered by
EditArchiveModal's tags field) and per-row filament-usage-history
edits (deferred - "restore deducted grams" is only consistent for the
most recent usage row per spool; needs a separate design call).
Reporter wanted to slice via the Bambu Studio sidecar but open files
locally in OrcaSlicer. preferred_slicer drove both the in-app
SliceModal sidecar selection AND the desktop "Open in Slicer" URI
handoff, so picking one forced the other.
New open_in_slicer setting (str | None) drives only the desktop URI;
null inherits from preferred_slicer so existing installs behave
identically. Storage in the existing app_settings key/value table;
GET normalises the "None" string back to null mirroring the
default_printer_id convention.
Frontend: Settings -> Slicer card adds a second dropdown ("Open in
Slicer" with "Same as API slicer" / Bambu Studio / OrcaSlicer);
ArchivesPage, MakerworldPage, ModelViewerModal switch desktop-URI
call sites to open_in_slicer ?? preferred_slicer. MakerworldPage's
"Slice in {{slicer}}" label additionally branches on useSlicerApi
so the label matches what the button actually dispatches.
Reporter on a multi-printer farm with 40+-plate runs needed to walk to
the printer with the right physical plate, but the queue and the
scheduling modal didn't surface curr_bed_type the way the archive card
already did.
New utils/threemf_tools.extract_bed_type_from_3mf helper (per-plate) so
both the queue API and the /plates endpoint can return per-plate values.
PrintQueueItemResponse.bed_type populated from archive.bed_type /
library_file.file_metadata['bed_type'] as the default, then overridden
per-plate via the helper when item.plate_id is set. /archives/{id}/plates
(and library equivalent) include bed_type in each plate object.
Per-plate accuracy matters because archive.bed_type is captured at
ingest as only the first plate's value (services/archive.py:235) -
a 40-plate 3MF mixing PEI + Engineering returns PEI at the archive
level for every plate. The helper re-reads the 3MF and returns the
truth.
Frontend: queue card meta row + PlateSelector per-plate row + PrintModal
header all render bed icon + canonical label via the existing
getBedTypeInfo() helper, same as the archive card.
Bambuddy's project_file MQTT payload hardcoded "nozzle_offset_cali": 2 (skip),
giving users on H2D / H2D Pro / H2C / X2D no way to control the same toggle
BambuStudio exposes. Critical for diamond-nozzle setups that must keep the
calibration off.
start_print() now takes a nozzle_offset_cali kwarg; the value is encoded as
1 (run) or 2 (skip) and gated on is_dual_nozzle so single-nozzle machines
always send 2 even if a stale flag arrives. The kwarg threads through
printer_manager, both background_dispatch sites, and print_scheduler so
every dispatch path respects the per-item setting.
print_queue gains a nozzle_offset_cali column (DEFAULT TRUE, is_sqlite()
branch for Postgres BOOLEAN). Settings default key default_nozzle_offset_cali
defaults to TRUE to match BambuStudio. Schemas updated across print_queue,
library FilePrintRequest, archive ReprintRequest, settings.
PrintModal renders the new toggle only when the selected printer is dual-
nozzle (printer-mode: nozzle_count===2; model-mode: DUAL_NOZZLE_MODELS).
SettingsPage default-print-options row + QueuePage bulk-edit tri-state both
hide unless any registered printer is dual-nozzle. Labels reuse the existing
settings.default* keys so the only new i18n strings are
settings.defaultNozzleOffsetCali / Desc and queue.bulkEdit.nozzleOffsetCali
- real translations in all 11 locales.
Backend correctly defers MQTT configuration when the AMS slot is empty
at assign time (state ∈ {9, 10}) — firmware drops the push silently;
on_ams_change replays the config once a spool is detected. The
response carries pending_config=true to communicate that. The
printer-card AssignSpoolModal was ignoring the flag and always
showing "Spool assigned and AMS slot configured" — the SpoolBuddy
modal has handled this since it shipped. Now mirrors the same
branch and shows "Assigned. Slot will configure when you insert
the spool." when pending_config is true.
Repro on every fresh demo subdomain: visitor lands on Printers OK, first
sidebar click sticks on a spinner (Chromium) or trips "Corrupted Content
Error" with sw.js stuck `activating` (Firefox). Manual reload recovers.
Root cause is the `client.navigate(client.url)` call added to the activate
handler in 18d534c9 (intended to force kiosks to pick up new bundles).
Its guard — `client.url && typeof client.navigate === 'function'` — does
not distinguish first install from upgrade. On any fresh origin (every
demo session, every first-time visitor, every cleared profile) it still
fired: Chromium raced it against the in-flight SPA mount; Firefox
deadlocked the activate's waitUntil on `await client.navigate(...)`
because the SW intercepts its own document fetch while still activating.
Split the lifecycle correctly:
- sw.js activate handler: just cache cleanup + clients.claim().
- sw-register.js: capture `hadController = !!serviceWorker.controller`
at load, listen for `controllerchange`, reload only when hadController
was true. Returning kiosk hits a new deploy -> had controller -> reloads
as before; first-install visitor -> no controller -> no forced nav ->
React mount completes.
Bump CACHE_NAME v29->v30 and STATIC_CACHE v28->v29 so existing browsers
fetching the new sw.js drop the old CacheStorage in the same pass.
SpoolBuddy unregister branch and notificationclick's client.navigate are
unrelated and unchanged.
ThreeMFParser._parse_3dmodel left XML-escaped values raw, so a Title of
"PCB Vise & Solder Station" landed in the DB as the literal "&" and
React re-escaped it on render to "&amp;". Apply the same
loop-until-stable html.unescape() the sibling ProjectPageParser already
uses, uniformly across all <metadata> values.
Same drop: rewrite the VP archive-name-source tooltip in all 11 locales.
BambuStudio 2.7.x (PrintJob.cpp:314-325) overwrites the user-typed
Send-dialog name with the slugified 3MF Title field whenever one is
present, so the previous "handy if you renamed the job in the send dialog"
copy was false. New text spells out the actual behavior; both Filename
and Metadata modes often produce the same string for that reason.
100vh and window.innerHeight report the layout viewport on iOS Safari, so
the popover's Start Drying button rendered behind the bottom toolbar overlay.
Switch the popover maxHeight to 100dvh and default computePopoverPosition's
viewportHeight to visualViewport.height (with innerHeight fallback). The
flip-above decision and the body-scroll fallback now both run against the
actually-visible area, so the footer button stays reachable on iPhone.
Reporter on an A1 Mini saw the AMS slot Configure dropdown render no
Bambu / Generic filament profiles, and saw the Profiles tab strip
A1 Mini results when filtering by that model. Bambu rolled out a
profile rename mid-2026: the @BBL <code> suffix on 106 cloud profiles
shifted from the long display form to a terse model code -- e.g.
"Bambu PLA Basic @BBL A1 Mini ..." is now
"Bambu PLA Basic @BBL A1M ...". User-authored profiles still use the
long form. Bambuddy's filters did a verbatim uppercase compare
("A1M" vs "A1 MINI"), so every renamed cloud profile silently
disappeared from the picker.
Centralize the alias check in slicerPrinterMatch.ts. New
PRINTER_MODEL_SUFFIX_ALIASES table maps "A1 Mini" <-> "A1M"
bidirectionally; exported matchesPrinterModelSuffix() does the
case-insensitive compare with the alias fallback. Two consumer
sites swap to the helper:
* ConfigureAmsSlotModal.tsx (Orca cloud and Bambu cloud filter
branches) -- the AMS slot picker, hit directly and reached from
SpoolBuddy's AMS page via mapModelCode(printer?.model)
* slicerPrinterMatch.ts:classifyByBambuName -- the SliceModal
Process / Filament compatibility check
Backend printer_models.py also gets a "Bambu Lab A1M" -> "A1 Mini"
entry so server-side 3MF model normalization stays consistent if a
3MF ever embeds the short form.
Kept the alias table narrow on purpose. Wide-net aliasing (e.g.
"X1" <-> "X1C") would silently collapse physically distinct
printers. When Bambu introduces the next rename, it is one new row
in the table -- /api/v1/cloud/settings is the place to grep, called
out in the source comment.
Non-proxy VPs (Archive / Review / Queue) with a target printer set up
a live-mirror bridge that forwards the slicer's MQTT and RTSPS auth
bytes through to the real printer. The slicer holds one code in its
profile (the one it bound the VP with), and that code has to satisfy
both the VP listener and the real printer at the far end of the
bridge. If the codes diverge the bridge silently fails at the second
hop — slicer reaches .49:8883, FINs before sending a ClientHello,
retries identically. The wiki framed the code-match requirement as a
camera-only concern; it isn't, all bridged protocols inherit.
Fix removes the foot-gun instead of re-documenting it. When a target
is selected on a non-proxy VP the access-code field switches to a
read-only display showing the target's code with an Eye-toggle
reveal; the backend auto-inherits on every create / update (any
explicit access_code submitted alongside a target is silently
overridden as belt-and-braces for non-UI clients). The required-when-
enabling check now treats target-set as satisfying the access-code
requirement. Standalone (no-target) non-proxy VPs still get the
editable input + Save button.
One-shot startup migration corrects any pre-existing mismatched
rows: SELECTs diverged VPs and logs one INFO line per row for the
audit trail, then UPDATEs via correlated subquery. Idempotent and
portable between SQLite and Postgres.
Bambu's end-gcode lowers the bed at gcode_state=FINISH. Bambuddy's
live-camera grab captured the bed already dropped, ruining the photo
framing. Source the photo from a brief Bambu timelapse instead —
firmware stops timelapse recording AFTER toolhead parks but BEFORE
bed-drop runs, so the last frame frames the finished print correctly.
When capture_finish_photo is on AND the user did not opt in to
timelapse for this print, force timelapse=True at dispatch + mark the
new PrintArchive.bambuddy_forced_timelapse column. After extraction
(success or failure), cleanup deletes the locally-attached file,
clears archive.timelapse_path, and walks the four scanner directories
(/timelapse, /timelapse/video, /record, /recording) trying FTP DELE
against the original filename. User-opted-in timelapses pass through
unchanged.
Resolver lives at services/background_dispatch.py::resolve_effective_timelapse
(module-level so the print queue can reuse it). Both dispatch paths
wired: background_dispatch.py (Print Now / Reprint) AND
print_scheduler.py:_start_print (the queue). Field testing caught the
scheduler gap on the first round — AST regression test now asserts
start_print(timelapse=...) references effective_timelapse, not the raw
item.timelapse, so a future refactor can't silently drop it.
Extractor: ffmpeg -i input.mp4 -update 1 -q:v 2 out.jpg. Decoded
frames overwrite the same output file, so the file left on disk is the
literal last frame regardless of duration. Bambu records one frame per
layer-change, so a 16-layer cube produces a 0.6 s timelapse — the
original -sseof -1.0 approach seeked before the start of the file and
returned frame 0 (empty bed). Decoding every frame is fine; Bambu
timelapses are short by construction even on hours-long prints.
Migration adds bambuddy_forced_timelapse branched on is_sqlite()
(DEFAULT 0 / DEFAULT FALSE — PG rejects DEFAULT 0 for BOOLEAN).
Verified live on postgres:16-alpine.
Photo-task wait_for budget extends 45s -> 75s when timelapse_was_active
so the notification carries the bed-up photo instead of falling back
to the live-cam grab on slow links.
Scope limit, documented in the camera wiki: prints started directly
on the printer touchscreen / Bambu Handy / Bambu Studio Send bypass
both dispatch paths, so the override doesn't fire there. Future
option: mid-print M981 S1 P20000 MQTT toggle in on_print_start.
Setting description rewritten in all 11 locales to drop the "only
works when timelapse enabled" caveat (Bambuddy now forces it) and
explain the kept-or-deleted behaviour.
SSDP multicast (239.255.255.250:2021) doesn't traverse routers, so a
printer behind a router on a different L3 segment was invisible to
"Discover Printers on Network". Docker mode had a CIDR text input but
only as a fallback when zero interface subnets were detected; native
mode had no subnet field at all.
AddPrinterModal now surfaces an always-visible subnet picker. Detected
interface subnets stay as dropdown options, plus a "Custom subnet..."
sentinel reveals a CIDR text input. When custom is picked, discovery
routes through POST /discovery/scan with the typed CIDR instead of
SSDP (which would no-op against a foreign subnet anyway). Last custom
CIDR persisted to localStorage so VLAN users don't retype every time.
Scan button label and scanning / no-printers-found strings all key off
(isDocker || useCustomSubnet) so wording stays "Scan Subnet..." /
"Scanning subnet..." whether the user is on Docker or just picked
Custom.
Backend unchanged: SubnetScanner.scan_subnet() already accepts any
CIDR, already caps at /22 (1024 hosts), POST /discovery/scan already
takes user-supplied input.