Reporter on X2D set a 1h AMS dry; the printer powered off seconds in.
Support log: every "Sent drying command duration=1" was followed 3-9s
later by "AMS 0 drying complete (dry_time 60 -> 0)" -- the completion
callback fired right after drying started, arming smart-plug auto-off.
Root cause: the tray-bearing branch of the AMS partial-update merge
rebuilt the unit as {**ams_unit, "tray": merged_trays}, never spreading
existing_unit. Tray-bearing partials carry no drying fields, so dry_time
(and info) was dropped; the falling-edge detector read the absent field
as 0 and saw a false 60->0 edge.
- Merge: tray branch now spreads existing_unit first, preserving
dry_time / info / humidity / temp across tray-only partials. Matches
the no-tray branch. Also fixes dry_status/dry_sub_status UI flapping.
- Detector: only evaluate the falling edge when dry_time is explicitly
present and parseable; skip otherwise without updating state.
Step 0 empirical test on 2026-05-20 disproved the "CLI cannot re-slice a
3MF for a different printer" assumption: feeding an 18-color H2D-bound
Trent900.3mf to the X1C bundle via /slice produces valid X1C G-code in
1.8s, with bed (256x256), kinematics, nozzle count, machine_start_gcode,
and bed_exclude_area all coming from the target bundle.
- SliceModal: drop !printerMismatch from isReady; remove the banner and
the sourcePrinterModel / printerProfileName / printerMismatch state
entirely. Cross-printer slicing is now indistinguishable from a normal
slice; the picker already shows the target printer.
- Remove slice.printerMismatch from all 8 locales.
- API cleanup: drop source_printer_model from /library/files/{id}/plates
and /archives/{id}/plates responses, drop the field from
frontend/src/types/plates.ts (PlateMetadata + LibraryFilePlatesResponse),
delete extract_source_printer_model_from_3mf from threemf_tools.py and
its 6 unit tests. Zero remaining consumers.
- i18n discipline cleanup in SliceModal.tsx (same drop): strip every
inline English defaultValue / positional fallback from t() calls (22
sites). Add slice.bundle / slice.bundleNone / slice.bundleAllRequired
to all 8 locales — they had no entry in any locale file and were being
served from the inline English fallback for every non-English user.
- Tests: rewrite the mismatch-warning test to assert "no banner, Slice
enabled" when models differ (regression guard); delete 2 obsolete
tests covering gate states that no longer exist.
Reporter on a P1S with non-RFID spools saw an old, almost-empty spool in
the AMS hover card's "Spulen-ID" block while the "Zugewiesen" block
correctly showed the freshly assigned full spool. Two layers compounded:
(1) Non-RFID slots fall back to a deterministic per-slot tag
(hash(serial) + ams_id + tray_id). The Link / Assign routes wrote
that tag to Spoolman extra.tag but never cleared it from the
previous holder on re-binding.
(2) The frontend's hover-card resolver preferred the (stale) tag-link
over the user's explicit slot-assignment. Same precedence bug in
SpoolBuddy's fill-bar resolver and slot-action picker.
Frontend: swap precedence at 5 sites — slot-assignment outranks tag-link
everywhere. FilamentHoverCard's existing match-dedupe then collapses the
two "Open in Inventory" buttons back into one.
Backend: new _clear_stale_tag_links() in spoolman_inventory.py, called
from POST /spoolman/inventory/slot-assignments (with the slot's
deterministic fallback tag) and POST /spoolman/spools/{id}/link (with
the literal tag being bound — works for RFID and fallback). Best-effort:
Spoolman 5xx and per-spool patch failures log + continue, never wedge
the bind. get_fallback_spool_tag_for_slot promoted to a public helper
mirroring the frontend's signature exactly.
The pre-insert MQTT probe added in 0.2.4.2 (b51598ea) had two bugs that
compounded on P1S firmware specifically:
1. Fixed 2-second sleep was too short. P1S broker + TLS handshake
routinely needs 3-5s to surface CONNACK on a cold MQTT session (same
firmware family with the documented "broker stops publishing but TCP
stays alive" quirk at bambu_mqtt.py:3181), so the probe falsely rejected
a printer that would have connected fine. H2C's broker is snappier and
cleared the 2s window without trouble — which is why the reporter's
H2C added without issue and only the P1S misbehaved.
2. client.disconnect() ran synchronously on the asyncio thread.
BambuMQTTClient.disconnect() ends in paho's loop_stop() which joins
the network thread; if that thread was still mid-TLS-handshake to the
slow P1S socket when teardown ran, the join blocked the asyncio thread
for as long as the handshake took to complete or fail. POST /printers
wedged, every other HTTP request queued behind it, Docker healthcheck
timed out — user-visible symptom: "the container hangs."
Fix:
- Replace the fixed sleep with a polling loop (8s budget, 200ms tick,
early-returns the moment state.connected flips True). Slow brokers
get the headroom they need; happy-path connects still finish in
~1-2s. Constants exposed as PROBE_TIMEOUT_SECONDS / PROBE_POLL_
INTERVAL_SECONDS class attributes so tests can dial them down.
- Move client.disconnect() to await asyncio.to_thread(...) so paho's
thread-join can never block the event loop.
The empty-card-report-prevention goal of the original probe stays
intact: a genuinely wrong access code still results in connected=False
after the 8s budget, the 400 with code=printer_connection_failed
still fires, the row is still never persisted.
Python 3.13 negotiates TLS 1.3 by default. The P2S firmware 01.02.00.00
vsFTPd build doesn't tolerate TLS 1.3's async session-ticket model on
the FTPS data channel — session resumption races, the data channel gets
torn down mid-stream, uploads land truncated at a chunk boundary, and
the printer replies 426 instead of 226. Visible to the user as "unable
to parse 3mf file" 30 s into the print.
Capping the SSL context's maximum_version to TLS 1.2 makes session
resumption synchronous and uploads complete normally.
Follow the per-model pattern established by camera_profiles.py in the
#1395 follow-up: add backend/app/services/ftp_profiles.py with a frozen
FTPProfile dataclass and a per-model registry. Only P2S (display name
+ N7 SSDP code) gets the cap today. X1C, H2D, P1S, A1 stay on negotiated
TLS 1.3 — the maintainer's dogfooded printers see zero behaviour change.
The snapshot-diff strategy in _scan_for_timelapse_with_retries needs
_timelapse_baselines[printer_id] populated at print start so the
completion-time scan can find the new MP4 by set-difference (mtime is
unreliable — LAN-only printers don't sync NTP).
The baseline-capture call was only in on_print_start's new-archive branch.
Queue / VP-dispatched / reprinted jobs take the expected-archive branch
which returns earlier, so the dict stayed empty and the completion-time
scan fell into the "take baseline now" fallback that snapshots after the
new file has already landed — no diff ever matches.
Extract the snapshot into _capture_timelapse_baseline_at_start and call
it from both branches.
PR #1434 CI flagged 5 B402 (ftplib import) in test_bambu_ftp.py and 2
B108 (hardcoded /tmp) in test_print_start_assigns_printer_id_to_vp_archive.py.
Both are intentional in tests: the FTP client tests need real ftplib
exception classes to construct mock 426 responses, and the /tmp path is
a MagicMock attribute never written to. Marked with `# nosec B402` /
`# nosec B108` plus a one-line justification each, matching the
convention from c2630399.
Reporter — the same person who originally requested the labels
feature in #809 — discovered that the ams_30x15 preset's 30x15 mm
dimension didn't actually fit any variant of the MakerWorld AMS
Filament Label Holder (model 752566) it advertised. Two new
presets replace it:
- ams_holder_74x33 (74 x 33 mm) matches the printable label STL
bundled in the MakerWorld project
- ams_holder_75x55 (75 x 55 mm) fits the cardstock-insert variant
the reporter validated on bench
Both cross the 20 mm height threshold so they land in the roomy
layout branch — swatch on the left, QR on the right, multi-line
text (brand, material, hex code, spool ID) in the middle. The
old 30x15 mm preset couldn't fit a QR code; the new ones do.
No DB migration: the preset name was never persisted. Callers
scripting the old ams_30x15 value get a clean 422 at the route's
Literal validator with the new valid values listed.
i18n: replaced inventory.labels.templates.ams.{label,hint} with
amsHolderSmall and amsHolderLarge across all 8 locales with real
translations; parity guard cleaned of the stale English-fallback
cognate entries. Parity holds at 4856 leaves per locale.
Tests: backend label renderer + integration tests cover both new
presets; LabelTemplatePickerModal test updated for the 6-button
grid and the new template value in the API-call assertion.
VP-queue archives are created with printer_id=None at queue-add
time because the scheduler hasn't picked a printer yet (and even
for explicit-printer queue items, the archive predates dispatch).
on_print_start's expected-archive branch updated status,
started_at, and subtask_id but never assigned printer_id, so
VP-queue-dispatched archives stayed permanently unassigned.
That broke every UI/API path gated on archive.printer_id —
critically the post-print "Scan for timelapse" action: the
H.264 file is on the printer's SD card and reachable via the
file browser, but the archive's scan endpoint refused the request
and the button stayed greyed out forever.
One-line fix: archive.printer_id = printer_id in the
expected-archive branch. Guarded against clobbering an
already-correct value so library-file queue items (which create
their archive with the printer pre-assigned) are idempotent.
Previous daily build (1fac0276) tightened the post-STOR voidresp
handler to fail on any ftplib.Error, stopping Bambuddy from
sending a print command for a truncated 3MF. Reporter
(@enjoylifenow on a P2S) then confirmed — after a clean SD-card
filesystem check, reformat, and power cycle — that v0.2.4.1
worked on the same hardware. That proves the 426 returned by
this firmware revision is noise: the TLS data-channel close
races the 226 confirmation, server reports failure, file is in
fact on the SD card.
Reverting wholesale would re-introduce the silent-truncation
bug from the original fix. Narrow the rule instead: after an
ftplib.Error from voidresp, run an FTP SIZE against the upload
path. SIZE matches the local file size → warn and proceed
(the reporter's case). SIZE mismatch, or SIZE itself raises →
fail loudly with full diagnostics (the original tightened
behavior — preserved).
Applied identically to upload_file() and upload_bytes() so the
A1-compatibility manual-transfer path is covered.
Tests: two regressions from the previous round renamed and
split into intact / truncated / size-check-fails. Intact-file
tests inject SIZE explicitly because pyftpdlib only flushes on
a clean voidresp — which can't happen when we monkeypatch
voidresp to raise. Docstring spells that out. 87 FTP unit tests
green; 118 FTP-touching tests across unit+integration green;
ruff clean.
The View-Timelapse-greyed-out behavior #1417 was originally
about stays untouched; once the reporter confirms upload
reliability is back, that diagnosis continues on a healthy
install.
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.
Step 2 of the camera architecture overhaul agreed after #1395. When
the camera viewer hits its error state OR before a print at any
time, a Diagnose button runs a staged check against the printer and
renders the result inline: which stage failed, how long it took,
and a translated remediation hint. Cuts off the "user opens a
'camera broken' ticket → ask for support bundle → triage" loop at
the user's screen.
Backend
- New `backend/app/services/camera_diagnose.py` orchestrator with
CameraDiagnoseResult / CameraDiagnoseStage dataclasses.
- New POST /printers/{id}/camera/diagnose route in camera.py.
- Stages:
tcp_reachable — TCP socket open to 322 (RTSP) / 6000 (chamber)
with 3 s timeout. Distinguishes timeout, refused, and host-
unreachable into distinct summary codes so the frontend can
show a precise remediation (firewall vs LAN-only off vs
wrong IP).
first_frame — captures one JPEG end-to-end via the existing
capture_camera_frame_bytes pipeline. Auth + RTSP handshake +
first keyframe collapse into one stage; the user-facing
answer is the same regardless of which sub-layer failed.
- Live-stream shortcut: when a viewer is currently watching the
camera with a buffered frame < 10 s old, the diagnostic skips
the real test and returns live_stream_active_healthy. Opening a
fresh socket would kick the live viewer off on single-camera-
connection firmwares (the #1348 reconnect-storm trigger), so we
trust the real-world evidence instead.
- Response surfaces protocol, port, and profile name for support
triage — lets us ask "what does your modal say?" instead of
"send the support bundle".
Frontend
- New CameraDiagnoseModal renders one row per stage with green-
check / red-X / grey-skipped icons, the per-stage duration in
ms, a remediation banner styled by overall status, and a Run
again button.
- Two entry points:
1. The viewer's error overlay grows a Diagnose button next to
Retry. Retry stays the primary action; Diagnose is the
escape hatch for users who can't see what's wrong.
2. A stethoscope icon in the viewer's always-visible control
bar, between Refresh and Fullscreen. Pre-flight testing
("did my firmware update break the camera?", "is the
camera up before I send a print?") doesn't require waiting
for the stream to fail first.
- Also lifted the previously-hard-coded "Camera unavailable" /
"Retry" strings into camera.unavailable / camera.retry so the
error UI is fully translated alongside the new keys.
Reporter on a P2S running firmware 01.02.00.00 saw the camera connect
for a few seconds then time out, repeating. P1S on the same install
worked fine — different protocol (chamber-image port 6000 vs RTSP via
ffmpeg).
The P2S RTSP path was running ffmpeg with `-probesize 32
-analyzeduration 0`, tuned for X1/H2 fast startup. The P2S's slower
keyframe pacing means ffmpeg can't lock onto the stream within 32
bytes — its own stderr says "consider increasing probesize" before
giving up after ~2s. Bambuddy reconnects, cycle repeats.
Instead of bumping the globals (which would regress every other RTSP
model's startup latency), this lifts the per-model tuning into a new
`camera_profiles` registry. CameraProfile dataclass holds the
previously-global knobs (probesize, analyzeduration, rtsp_reconnect_max,
rtsp_reconnect_delay, plus an extra_ffmpeg_input_args hook for future
per-model flags). get_camera_profile(model) returns the model's profile
or DEFAULT_PROFILE.
Default profile preserves the historical X1/H2 fast-startup values
verbatim — X1, X1C, X1E, X2D, H2C, H2D, H2D Pro, H2S all see no
behaviour change. P2S is the only override:
P2S: probesize=1_000_000, analyzeduration=500_000
SSDP internal codes (N7→P2S) resolve via an alias map so the camera
path works during the early-connect window before the display name
is settled.
This is the first step of the camera-architecture overhaul agreed
after #1395. Adding the next quirky model is a config entry, not
another module-level constant.
Reporter sliced in OrcaSlicer with timelapse on, sent the job to a VP
queue, started from the queue, and got no timelapse video. Their
dispatch chain itself was correct (queue item -> scheduler -> MQTT
command honors `timelapse`); the gap was at queue-add time.
The VP's `_add_to_print_queue` reads `default_timelapse` (and the four
other print-option settings) from the workflow settings card. That was
introduced in #1235 to stop column-level defaults from winning. But it
also discarded the slicer's actual choice carried on the MQTT
`project_file` command, which all the slicers (Studio / Handy / Orca)
ship as `timelapse: true|1`. Result: a user with the new-install value
`default_timelapse=false` had to either flip the global setting or
edit every queue item by hand, even though their slicer's "Print
options" UI clearly said "record timelapse".
Investigation went wider than #1403 because Martin's hypothesis was
"the print options modal isn't respected either." Cross-checking
86 captured P1S `project_file` commands across the support packages
shows 46 from the queue scheduler and 33 from background_dispatch
emitting `"timelapse": true` correctly to real printers - the modal +
re-print path is intact end-to-end. The slicer-side gap was the only
real bug. Two unrelated dead-code issues turned up in the same dig and
are folded in below.
Fix (VP queue inheritance)
- `on_print_command` in the VP manager now stashes the slicer's
project_file dict keyed by filename, then signals an asyncio.Event.
- `_add_to_print_queue` checks the dict first; if empty, creates the
event and waits up to 2 s for it before reading the settings
fallback. Each option flows through per-field - slicer value wins
if present, else the existing settings default (so users who
explicitly set `default_timelapse=true` in their VP workflow card
still get that on slicers that don't send a print command).
- MQTT field naming preserved exactly: `bed_leveling` (single L) on
the wire stays mapped to `bed_levelling` (double L) on the Bambuddy
column. Integer 0/1 from H-family slicers and bool true/false from
P1/X1 slicers both coerce via `bool()`.
- Capture is gated on `mode == "print_queue"` so immediate / review /
proxy modes keep their pre-fix no-op `on_print_command` and don't
accumulate stashed entries over the VP's uptime.
- Wait is also skipped when there's no MQTT server attached
(`self._mqtt is None`), so unit tests that invoke
`_add_to_print_queue` directly don't pay the 2 s tax.
- Capture is consumed on use so the dict stays bounded.
- `printer_manager.get_status(...).get(...)` against a `PrinterState`
dataclass that has no `.get()` method.
- Every print option discarded (timelapse, bed_levelling, AMS mapping).
The route 500'd before ever reaching the printer. Rewritten to mirror
`POST /print-queue/{item_id}/start`: clear `manual_start=False` on the
next pending queue item and let the scheduler dispatch with the
queue's stored options intact. Response shape preserved.
Side-bug b: vibration_cali default drift in background_dispatch
- `ReprintRequest.vibration_cali` and `FilePrintRequest.vibration_cali`
both default to `True` (matches Bambu Studio behavior for X1/P1).
- Both `_process_job` call sites read
`job.options.get("vibration_cali", False)`.
Cosmetic today because the frontend always sends the field, but a
latent landmine for any future caller that bypasses the schema. Both
sites flipped to `True`.
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.
bambu_ftp.upload_file (and upload_bytes) wrapped the voidresp() call in a
broad "except Exception: log warning and proceed" because H2D printers
can take 30+ seconds to send the 226 and we don't want to fail on that.
But the same handler was swallowing ftplib.error_temp (e.g. 426 "Failure
reading network stream") from buggy printer firmware, which explicitly
means the data stream was cut mid-transfer and the file on the SD card
is partial.
Bambuddy then sent the print command anyway, and the printer surfaced a
generic "unable to parse 3mf file" error 30 seconds into the print
attempt -- with nothing in the log on the user side to suggest the
upload had actually failed.
Split the catch: ftplib.Error subclasses (server-reported failure)
re-raise so the outer handler returns False; everything else (socket
timeout etc.) keeps the existing proceed-with-warning behaviour so the
H2D 226 tolerance survives.
Two regression tests patch _ftp.voidresp to raise error_temp("426 ...")
and assert both upload_file() and upload_bytes() return False.
The underlying P2S firmware / TLS-data-channel issue that triggers the
426 for the reporter is separate -- this change just stops Bambuddy from
hiding it.
Reporter Kyobinoyo asked for the equivalent of the existing
print-finish auto-off but triggered when AMS drying ends.
Two new SmartPlug columns: auto_off_after_drying (default false),
off_delay_after_drying_minutes (default 10 — AMS chamber is hot
post-cycle so longer cooldown than the print-finish default of 5).
SQLite + Postgres migrations both idempotent.
Trigger lives in BambuMQTTClient — per-AMS _previous_dry_times
tracks the dry_time > 0 → 0 falling edge and fires a new
on_drying_complete(ams_id) callback. Plumbed through
PrinterManager.set_drying_complete_callback to
SmartPlugManager.on_drying_complete(printer_id, db), which walks
linked plugs and respects the per-plug toggle. Catches queue,
ambient and manual drying identically because it observes firmware
state, not scheduler intent.
Frontend: single "Auto Off After Drying" toggle + delay input on
the smart plug card, next to the existing print-finish auto-off
section.
Per-AMS plug routing (separate plug for AMS only, per-AMS targeting
on dual-AMS printers) deferred — Bambuddy's plug model is
plug→printer, so the trigger fires whenever any AMS on the linked
printer finishes a cycle.
Reporter vmhomelab ran a Print Queue VP against a P1S, opened
BambuStudio, and saw only the External Spool. Toggling Auto-Dispatch
(which restarts the VP) made AMS briefly appear, then it reverted to
defaults. Proxy Mode worked fine.
The earlier #1371 sticky-keys fix only handled one of two firmware
incremental-push shapes: it preserved cached `ams` when the incoming
push OMITTED the key entirely. P1S firmware (01.09.01.00) instead
sends incrementals with the `ams` key present but the inner `ams.ams`
array stripped — `{ams_status: 1, humidity: 2}` rather than
`{ams: [...], ams_status: 1}`. To the existing "key present? leave it"
check that read as "no need to preserve," so the bridge cache got
overwritten with the stripped blob, the slicer's next 1 Hz read saw
`ams` with no unit list, and BambuStudio fell back to its "no AMS"
default render. Toggling Auto-Dispatch restarted the VP and got a
fresh pushall through; the next P1S incremental stripped it again.
H2D rarely trips this because its incrementals typically don't carry
`ams` at all, so #1371 alone was enough — which is why H2D users
(including the project owner) didn't see the bug while P1S/A1 users do.
Fix: deep-merge the `ams` key inside the bridge cache. Mirrors the
structure Bambuddy itself already does in
`bambu_mqtt.py::_handle_ams_data` — scalar fields take the new value,
but the `ams.ams` array is merged unit-by-unit by `id`, each unit's
`tray` array is merged tray-by-tray by `id`, and units / trays the
incremental doesn't mention survive intact from the cached full
state. A tray-targeted incremental during a print
(`{ams: [{id: 0, tray: [{id: 0, state: 11}]}]}`) now updates that one
tray's state without dropping the other trays' tray_type / tray_color.
Helper added as `_merge_ams_dict` next to `_ip_to_uint32_le`, called
from the existing sticky-keys block when both prev and new carry the
`ams` key as dicts. Other sticky keys (vt_tray, net, ipcam,
lights_report, ams_extruder_map, mapping) keep the prior absent-only
preservation; only `ams` has the multi-shape partial problem worth
the merge complexity.
Reporter IndividualGhost1905 upgraded to 0.2.4.1 (which shipped the
per-event aggregation rewrite from #1378) and saw Quick Stats split
between consistent values (Total Prints, Print Time, Filament Used,
Energy, Success Rate matched the archive list) and zero-or-empty ones
(Filament Cost, Time Accuracy).
Root cause: #1378's migration added six columns to print_log_entries
- archive_id, cost, energy_kwh, energy_cost, failure_reason,
created_by_id - but never backfilled them. Pre-upgrade rows kept NULL
on all six. The new Quick Stats query sums PrintLogEntry.cost (gets 0
on legacy data); the time-accuracy query JOINs PrintArchive ON
archive_id (drops every legacy run from the average). Counts and the
pre-existing per-row fields (status, duration_seconds,
filament_used_grams) kept working - which is why some panels looked
right and others didn't.
Two-step backfill added inside run_migrations next to the existing
column-add block, as DML inside begin_nested() (not _safe_execute,
which is documented DDL-only):
Step 1: link each orphan log entry to its archive via
print_name + printer_id (highest archive id wins on
tiebreak - newest matches the overwrite-then-stop shape
pre-#1378 reprints left behind).
Step 2: copy archive.cost / energy_kwh / energy_cost onto the
latest matching log entry per archive, BUT only for
archives where no log entry yet carries a cost. That
second clause is the idempotency anchor and the
double-count guard for users running this after #1378
has already written cost-bearing rows for new runs -
those archives are left untouched.
Earlier reprints stay NULL, matching the "first/latest writes, rest
stay NULL" convention #1378 introduced for new prints. Sum across the
legacy reprint chain reproduces sum-of-archive-cost exactly, so Quick
Stats Filament Cost matches the pre-upgrade total instead of dropping
to zero.
SQL is plain ANSI - correlated UPDATE with LIMIT 1 in the SET
subquery, WHERE id IN (SELECT MAX(id) ... GROUP BY archive_id HAVING
SUM(CASE WHEN cost IS NOT NULL THEN 1 ELSE 0 END) = 0). Verified
end-to-end on SQLite (4 new unit tests in
test_print_log_backfill_migration.py: link-via-name, latest-run-gets-
cost, idempotent, skip-archives-with-any-costed-run) and against a
live postgres:16-alpine + asyncpg container (first-pass and second-
pass produce identical state).
The other widgets the reporter listed (Printer Stats, Filament
Trends, By Material, Success by Material, Color Distribution) iterate
the archives list on the frontend rather than calling /stats - they
read consistent pre-upgrade data and aren't part of this fix; the
inconsistency between them and Quick Stats resolves once the backfill
brings Quick Stats in line.
Reporter pgladel edited a spool's Color Name in Spoolman mode, hit
Save, and saw the value snap back to the subtype on the next read.
The earlier #1319 fix correctly handled the read/form-prefill half
(the color_name_is_synthesized flag, blank-on-synth form init), but
the write half assumed Spoolman has a `color_name` field on Filament.
It doesn't. Verified against the live FilamentUpdateParameters schema
on Spoolman 0.23.1 — the accepted fields are name, vendor_id, material,
price, density, diameter, weight, spool_weight, article_number,
comment, settings_extruder_temp, settings_bed_temp, color_hex,
multi_color_hexes, multi_color_direction, external_id, extra. No
color_name. Spoolman's PATCH happily returns 200 for
{"color_name": "Red"} and silently discards the unknown key, so
find_or_create_filament was either patching a void or creating
filament after filament with the same field-that-doesn't-stick (which
is what produced the "BB also created a bunch of new filaments"
duplicate trail on each save attempt).
The fix follows the same pattern as the existing BambuStudio slicer-
preset storage: persist color_name on spool.extra.bambu_color_name as
a JSON-encoded string, register the extra field via
ensure_extra_field before write (Spoolman 400s on unknown extra keys),
and read it back in _map_spoolman_spool with priority
extra > filament.color_name (forward-compat for any future Spoolman
release that adds the field) > subtype synth.
Dropped the now-dead color_name passing through
find_or_create_filament and create_filament — Spoolman would discard
it anyway and keeping the dead pipe risked the same confusion the
next time someone reads this code. The previous "match by name then
patch color_name" loop is gone; what survives is the name-match
resilience that lets an AMS-sync-created filament named "Glow" still
match the user-driven edit's composed "PLA Glow", which prevents
re-introducing the duplicate-filament trail.
The frontend form's color_name_is_synthesized handling is unchanged
— that part already worked.
Reporter MartinNYHC opened the Add Smart Plug dialog in HA mode, typed
a search prefix matching a multi-entity device (one switch.* plus
several sensor.*/binary_sensor.* siblings under the same friendly
name), clicked one of the non-switch siblings, and got a 422 on Save:
String should match pattern
'^(switch|light|input_boolean|script)\.[a-z0-9_]+$'
The screenshot confirms the bug shape — the X button next to the
"empty-looking" Select Entity field only renders when haEntityId is
truthy. So haEntityId was set, but selectedEntity (haEntities.find by
that id) returned undefined, so the input rendered the placeholder
text instead of the friendly-name display. That can only happen when
the user had earlier picked an entity whose domain is NOT in the
schema's allowed list, then the search cleared, the entity-list
refetched without a search param, and the refreshed list (filtered to
the default domains) no longer contained the user's pick.
Root cause was in HomeAssistantService.list_entities: when a search
query was present, the function bypassed the domain filter entirely
and returned matches across every HA domain. Offering a clickable
choice the schema can't accept is broken UX, and the cryptic Pydantic
pattern echo on save made it look like a backend/schema problem
rather than a search-permissiveness problem. Confirmed via git diff
that the smart-plug code path is unchanged between v0.2.4 and
0.2.4.1 — this has been latent since the script-domain commit in
February 2026, only noticed now because the reporter hadn't reopened
the modal in months.
Fix: always apply the allowed-domains filter ({switch, light,
input_boolean, script} — kept in sync with the regex in
backend/app/schemas/smart_plug.py:17). Search composes on top as a
substring match against entity_id or friendly_name, instead of
replacing the domain filter. Whitespace-only search strings now
fall back to the no-search behavior.
H2S is single-nozzle (nozzle_count=1 across 9+ stored support bundles
and the reporter's diagnostic) but had been added to the H-family model
gate in start_print_job. That single flag controlled both the firmware
bool->int format (legitimately needed for the whole H-family, including
H2S) and the dual-nozzle external-spool routing (correct only for actual
dual-extruder printers).
With no AMS attached and an external-spool slot (tray_id=254), the
dual-nozzle branch wrote ams_id=254 into ams_mapping2 instead of the
canonical 255 — exactly the failure the comment six lines above warns
against. Firmware rejected the dispatch with 07FF_8012 "Failed to get
AMS mapping table". The use_ams=False fallback was also being skipped
because the H-family bypass was meant for dual-nozzle routing.
A second site at bambu_mqtt.py:3987 and its sibling at kprofiles.py:119
detected dual-nozzle by serial prefix ("094", "20P9", "31B8B"). H2S
shares prefix "094" with H2D, so prefix detection misclassified it too.
Split the conflated flag into two:
- is_h_family — firmware format (int 0/1 for calibration fields).
Includes H2S. H2S firmware structurally accepted the current command
shape (failure was at AMS routing, not parsing), so the int format
stays for H2S.
- is_dual_nozzle — external-spool routing and use_ams gating. Excludes
H2S. Source-of-truth is the runtime _is_dual_nozzle flag set from
device.extruder.info, with a model-name fallback for the brief window
after connect before push data arrives.
The K-profile delete site and the kprofiles route now use the same
runtime+model check instead of serial prefix.
The CI-only failure on test_layer_timelapse_expected_archive came from
the test driving the entire on_print_start flow through ~12 patches and
a MagicMock printer, which behaved differently between Python 3.11 (CI)
and 3.13 (local) — execution stopped silently somewhere in the
expected-archive path under CI's pytest-xdist parallelism but completed
locally.
Fix root-shape instead of fix the symptom:
1. Extract the three identical start_session call sites in on_print_start
(expected-archive promotion at 2030, fallback archive at 2554, fresh
archive at 2644) into one helper _maybe_start_layer_timelapse() with
the same external_camera_enabled / external_camera_url guard. The
three inline blocks had already started drifting (#1353 originally
only fixed one of them on the first pass) — the helper keeps them
locked together going forward.
2. Rewrite the test to call the helper directly. Uses SimpleNamespace
(strict attribute access) instead of MagicMock (default-truthy), no
DB mocking, no event loop, no parallel-state surface. Four small
cases instead of two integration-style ones: enabled→starts,
disabled→skips, URL-missing→skips, camera_type default 'mjpeg'.
Test was passing locally but failing in CI on the merge of v0.2.4.1.
Root cause: MagicMock returns a truthy default for any unset attribute,
so mock_printer.plate_detection_enabled was truthy and the production
code's plate detection block ran for real. Locally with ffmpeg present
the capture path completed cleanly (try/except swallowed errors) and
flow continued to start_session. In CI without ffmpeg, the failure path
in capture_camera_frame_bytes prevented execution from reaching the
expected-archive branch's start_session call, so the assert_called_once
on the patched start_session tripped.
Plate detection isn't the subject under test (start_session in the
expected-archive branch is). Setting plate_detection_enabled = False
explicitly on the mock skips that block entirely and makes the test
robust to environmental differences. Test also drops from ~9s to ~2s
since real frame-capture attempts no longer run.
Synthetic value on mock_archive.file_path was "/tmp/fake.3mf" - Bandit
flagged it as "Probable insecure usage of temp file/directory" even
though the string is never used as a filesystem path. Swapped to
"/test/archives/fake.3mf", matching the pattern used in commit 9f1188d7
to clear the same finding on test_library_trash_api.py and
test_pending_upload_display_name.py. No suppression comment needed -
the new path no longer matches Bandit's regex.
Obico polling could freeze the live camera stream within seconds of opening
the viewer. Cause: when the buffer-reuse path in obico_detection._capture_frame
saw an empty _last_frames[printer_id] entry (stream startup before the first
JPEG lands, or upstream mid-reconnect after a 30s read timeout), it fell
through to capture_camera_frame_bytes() and opened a second RTSP socket. On
firmwares that allow only one camera connection, that second socket forced
the printer to drop the live fan-out connection - the viewer's ffmpeg then
hit its own 30s timeout, looped through 30 reconnects at 0.2s, all racing
the next Obico poll, and the broadcaster pump exited.
Widen the gate from "do we have a buffered frame?" to "is any fan-out stream
registered for this printer?". New is_stream_active() helper checks
_active_streams / _active_chamber_streams independently of buffer state.
_capture_frame consults it first: if a viewer is attached, it returns the
buffered frame when available or None (skip this poll cycle) when not. Never
opens a competing socket while a viewer is connected.
Cost: at most one missed Obico detection cycle per viewer-attach (~10s lag).
Benefit: zero competing-socket events while any viewer is connected.
try_get_active_buffered_frame() refactored to delegate to is_stream_active()
so the two helpers stay in lockstep. The /camera/snapshot caller is unchanged
behaviorally (snapshot is a user-initiated single-shot; falling through to
fresh capture on an empty buffer is the desired behavior there).
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.
The bridge cache replaced _latest_print_state wholesale on every
push_status arrival. Bambu firmware sends full pushall responses
(with AMS/vt_tray/net.info/lights_report) on reconnect / pushall
requests, but ~1 Hz incremental updates with only the fields that
changed. The first incremental push after a pushall therefore wiped
AMS info from the bridge cache, and slicers reading the cache (via
the VP's 1 Hz status push) saw a stripped-down state with no AMS
visible until the next pushall — typically only on a manual printer
power-cycle.
Preserve a small set of slicer-visible sticky keys from the previous
cache when the incoming push doesn't carry them: ams, vt_tray,
ams_extruder_map, mapping, net, ipcam, lights_report. Mirrors the
same pattern Bambuddy uses for its own internal state.raw_data.
Both the queue-side _watchdog_print_start and the direct-dispatch
_verify_print_response used `status.state != pre_state` to decide
whether a project_file command had been accepted. When a printer was
in FINISH at dispatch time (un-dismissed post-print prompt from a
prior job), the firmware silently rejected the new command; if the
user then dismissed the screen prompt, the printer moved FINISH ->
IDLE and the watchdog returned early as "command landed" — leaving
the queue row stuck at status='printing' indefinitely and the
scheduler permanently marking the printer as busy.
Narrow the "command landed" check in both verifiers to an allow-list
of active-print states (PREPARE / SLICING / RUNNING / PAUSE).
Inactive transitions (FINISH -> IDLE, etc.) no longer short-circuit
the revert. The subtask_id-advance signal stays in place for H2D's
slow FINISH -> PREPARE transition (#1078).
Also wrap _watchdog_print_start's revert commit and
printer_manager._persist_awaiting_plate_clear in run_with_retry so
SQLite single-writer contention can't silently drop these writes.
The revert path returns a tristate sentinel so the post-revert MQTT
session-recovery logic only runs when we actually reverted (or the
commit failed) — not when on_print_complete had already cleared the
row, where a forced reconnect could break a healthy concurrent print.
The #765 guard against shutdown-time data wipes skipped any AMS update
with power_on_flag=False, but some X1C firmware emits power_on_flag=False
while idle with tray_exist_bits still reflecting the real slot inventory.
Older firmware (01.08.02.00) doesn't emit per-tray state=9/10 events, so
the bitfield path is the only signal — muting it left spool removals
undetected until a manual reconnect.
Narrow the skip to the exact shutdown pattern: zero bits AND
power_on_flag=False. Non-zero bits with power_on_flag=False are now
applied. The #765 shutdown protection is preserved (its regression test
uses tray_exist_bits='0' and still passes); newer firmwares are
unaffected because their per-tray state path catches the removal first.
Discord's "Copy Webhook URL" button emits discordapp.com URLs; both
hostnames serve the same webhooks. The validation now accepts either
prefix while keeping the check itself in place to catch the
paste-the-wrong-thing error.
archives stop reporting near-zero cost (#1344)
Reporter @nicktags hit $0.01 on a 110.3g multi-color print with the
global default filament cost set to $10/kg. archive.py initial cost
calc was correct (~$1.10), then usage_tracker.on_print_complete
overwrote archive.cost with sum(r.cost for r in results) -- where
results only includes AMS trays mapped to a spool in Bambuddy's
inventory. On a multi-color print where 3 of 4 used trays had no
inventory spool, only the one tracked slot's tiny share (~1g) survived
and the archive recorded $0.01.
The overwrite logic dates to #505 (Feb 2026) and is correct for
fully-tracked single-color prints, but the multi-color slicer feature
in 0.2.4 (988c0055) made the partial-inventory state common -- users
slice + print multi-color from Bambuddy without first setting up an
inventory entry for every tray.
Cover the gap: any filament weight not represented in results gets
charged at the global default rate. For a fully-tracked print,
untracked grams = 0 and the top-up adds nothing, so the single-color
behavior is preserved. For a partial print, the missing slots are
priced at the user's documented default rate so the archive cost
reflects the whole print.
Three call sites updated to share the same logic:
- usage_tracker.py: live cost-update on print complete
- archives.py rescan_archive: per-archive manual recalc
- archives.py recalculate_all_costs: bulk recalc button
Reporter @Andlar94 ran the external-camera flow on an A1 dispatched via the
print queue and got no MP4 output even though the log said "Stitching layer
timelapse for printer 1" after each print. Support bundle confirmed the
external camera was working (Obico was polling the snapshot URL fine for
plate detection).
Root cause: start_session() only ran in the two new-archive paths in
on_print_start (fallback_archive at main.py:2510 and regular new-archive at
2600). The expected-archive branch at main.py:1981-2052 — where every
reprint and every queue/VP-dispatched print lands — updated the existing
archive row to status=printing but never started a timelapse session.
So _background_layer_timelapse ran at print complete, called tl_complete(),
found nothing in _active_sessions, returned None silently, and the wrapper
at main.py:3917 produced no log message for the no-session case. Every
print through the queue silently lost its timelapse — likely the reason
this hasn't been caught before (direct slice-and-send-to-printer prints
take the new-archive path and work fine).
Fix: mirror the same start_session() call in the expected-archive branch,
guarded by the same external_camera_enabled + external_camera_url check the
other two paths use.
Also reworded the snapshot URL help text across all 8 locales to make clear
that timelapse and plate detection each require their own per-printer
toggle — the URL is just the image source they pull from when active. The
previous wording read as if filling in the URL was sufficient.
Follow-up to the #1322 root fix. Reporter @RosdasHH traced the raw MQTT
payload and found that P1S and A1 Mini send only {"id": N} for a
physically empty slot — no state, no tray_type, no other fields. Without
that signal, the assign-spool path was firing one wasted MQTT publish per
click on a truly-empty slot (firmware dropped it silently, but still).
The AMS parser in printer_manager.py now detects the bare-tray shape and
promotes it to state=9 — the firmware's explicit "no spool" code — which
lets the existing state in {9, 10} short-circuit in the inventory route
apply automatically.
The detection is intentionally narrow:
len(tray) == 1 and "id" in tray and state is None
so the post-Reset-Slot A1 Mini BMCU case (populated payload with state=3
and tray_type="") has more than one key and stays unaffected — the #1322
root fix is preserved.
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)
The firmware update dialog showed "01.11.02.00 newer · Unavailable" with the
misleading error "Firmware file is not available from Bambu Lab" while the
logs spammed "Failed to get Bambu Lab page: 403". The wiki scrape was fine —
only the Next.js buildId fetch on bambulab.com was being blocked by Cloudflare
on the reporter's network, and the buildId was cached in memory only, so a
single 403 broke download-URL resolution for the rest of the session.
- Send Accept + Accept-Language headers alongside the honest Bambuddy/1.0 UA
so the request stops tripping Cloudflare's "bare scraper" signal.
- Persist the buildId to <data_dir>/firmware/build_id.json so a transient
403 or a backend restart can't wipe a previously-valid buildId.
- Add a download_page_unreachable flag and use it in the prepare-update flow
to render an honest error ("page unreachable from this network — try later
or download manually from bambulab.com") instead of implying Bambu doesn't
have the file.
- Retry the per-model JSON once when a cached buildId returns 404 (page
rebuild), give up gracefully on 403 without churning.
* 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.
Slicing an STL via the integrated slicer always defaulted to whatever
curr_bed_type lived in the chosen process preset (typically "Cool
Plate"), which the slicer CLI rejected for high-temp filaments with
"Plate 1: Cool Plate does not support filament 1". The user had no
way to switch plates without cloning the preset in BambuStudio.
The Slice modal now exposes a Build plate dropdown with the six
canonical BambuStudio / OrcaSlicer plates (Cool Plate, Cool Plate
SuperTack, Engineering Plate, High Temp Plate, Textured PEI Plate,
Smooth PEI Plate) plus an "Auto (use process preset)" option that
preserves the previous behavior. Positioned between Process profile
and Filament rows so a long filament list never pushes it off the
modal's scrolled viewport, and always enabled regardless of whether
the user picked a Printer Preset Bundle.
A new bed_type field on SliceRequest flows through both dispatch
paths:
- Resolved-preset path: _patch_process_bed_type overwrites
curr_bed_type on the process JSON before forwarding to the sidecar.
Works end-to-end today, no sidecar change needed.
- Bundle dispatch path: slice_with_bundle adds a bedType form field
to the sidecar multipart. The sidecar (maziggy/orca-slicer-api
fork) needs a matching change to honor it as --curr_bed_type on
the CLI invocation; until then the field is silently ignored and
the slice runs with the bundle's default plate.
Bambuddy's external SpoolmanDB lookup in `_find_or_create_filament` matched
on material+color only, with no manufacturer filter. Because SpoolmanDB is a
multi-vendor catalog and entries are roughly ID-sorted, the first hit for
any common combination is almost always a competitor — `bambulab_pla_black_1000_175_n`
is the 15th entry for PLA + `#000000`. Bambu Lab RFID spools were being
labeled with competitor product names (`3DJAKE Black`, `3DXTECH™ Black`, etc).
Restrict the external-library loop to entries whose manufacturer is
`"Bambu Lab"` (with `id.startswith("bambulab_")` as a defensive fallback
for schema drift). When multiple Bambu Lab candidates exist, prefer the
entry whose `name` equals the AMS `tray_sub_brands` so `"PLA Basic"` wins
over generic `"Black"` when both are present. Forward `density` from the
chosen external entry so it is no longer overwritten by the PLA-default
1.24 in `create_filament`.
Six unit tests added: internal short-circuit preserved, non-Bambu external
entries skipped, PLA Basic > generic PLA tiebreaker, no-match fallback,
id-prefix defensive fallback, density propagation.
Fixes#1309
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: MartinNYHC <mz@v8w.de>
On A1 / A1 Mini, clicking the "Up" arrow on the printer-card bed-jog
control sent the nozzle straight into the build plate. Reporter
triggered it with the 50 mm step and crashed their nozzle.
Root cause: the bed-jog UI was designed against the X1 / P1 / H2 family
where the bed is the Z-axis and Bambu's firmware homes Z=0 at the top,
so G1 Z- raises the bed toward the toolhead (decreases the nozzle-bed
gap). The frontend maps "Up" to negative distance with that convention
in mind.
A1 / A1 Mini are bed-slingers: bed moves on Y, toolhead moves on X+Z,
firmware uses standard cartesian Z (Z+ = toolhead up). On those models
G1 Z-10 drives the toolhead DOWN 10 mm. There was no model
classification at the bed-jog code path, so every printer got the same
X1-convention G-code.
Fix: new is_bed_slinger(model) helper in printer_manager (sibling to
existing supports_chamber_temp / has_stg_cur_idle_bug, reuses the
already-defined A1_MODELS frozenset which covers display names and
internal codes N1 / N2S). The bed-jog route now inverts the signed
distance before emitting G-code when the printer model is in that set,
so UI "Up" semantics ("decrease nozzle-bed gap") stay consistent
regardless of which physical part moves. Frontend untouched, single
source of truth lives in the backend, keyed off the Printer.model
column. Route Query description and docstring updated to spell out the
new contract: distance is the gap adjustment, not the raw Z value.
Editing a spool's color name on Spoolman-backed inventory appeared to
accept the new value but the inventory list column and the next edit
showed it back to the subtype. Three layers stacked to produce this:
1. find_or_create_filament matches by material/name/color_hex/vendor —
color_name is intentionally not part of the match key, but on a
match it returned the existing filament's id unchanged, silently
dropping the new value.
2. The read helper falls back to subtype when filament.color_name is
empty (kept on purpose: without it Spoolman installs that don't
fill the field render every spool as "Unknown color").
3. The edit form prefilled color_name from spool.color_name — which
on those installs was the synth value. Changing subtype but not
color_name silently round-tripped the OLD subtype back to Spoolman
as if it were a real user-set color_name.
Fixes:
- find_or_create_filament now patches the matched filament's
color_name via the existing patch_filament wrapper when the request
differs. Parameter convention: None = don't touch, "" = explicit
clear, any other string = set/update. A patch failure is logged but
does not block the match.
- The PATCH route uses model_fields_set to distinguish "field omitted"
from "field explicitly set to null" (mirrors the existing
storage_location pattern at the same site).
- The map helper returns color_name_is_synthesized: bool. The edit
form leaves the input blank when true, so the user sees the real
stored state and can't accidentally round-trip the synth value back.
fix(auth): cleanup orphan OIDC/MFA rows on user delete (#1285)
Three User-FK tables (user_oidc_links, user_totp, user_otp_codes)
declare ON DELETE CASCADE in their models, but SQLite ships with
PRAGMA foreign_keys=OFF (the project's existing pattern, mirrored
for APIKey in PR #1182). Without explicit DELETEs, deleting a user
on SQLite leaves orphan rows behind:
Issue #1312 follow-up. Investigation traced the "Name cannot be empty"
report to a sidecar image pre-dating the /profiles/bundle endpoint
addition. Two changes so the next occurrence is self-diagnosable from
the support bundle without a manual curl.
Backend: new _fetch_slicer_health(url) helper does a 2s GET on /health,
walks every non-dataPath key under checks looking for a version field
(the wrapper labels both sidecars as checks.orcaslicer regardless of
which CLI is bundled). _collect_slicer_api_info now exposes
bambu_studio_version and orcaslicer_version. Strips trailing slash
before appending /health to avoid double-slash 404s.
Docs: bambuddy-wiki/docs/features/slicer-api.md gains a Quick Start
callout that branch-built sidecars don't auto-update, a corrected
/health troubleshooting entry (both "unknown" version and "orcaslicer"
field name on bambu-studio-api are cosmetic wrapper bugs, not stale-
image indicators), a new "Name cannot be empty" troubleshooting entry,
and an Updating section that requires --no-cache --pull together
(BuildKit caches the git context separately from layers, so --no-cache
alone silently reuses the old checkout).
The route mapped sidecar 4xx/5xx to HTTPException with detail but never
logged it. Reporters seeing a 400 toast were giving us only the status
code, not the reason, and the access-log line was all that landed in
support bundles.
Add WARNING-level logs on each error branch (400 SlicerInputError,
503 SlicerApiUnavailableError, 502 SlicerApiError) with the sidecar's
own message + the filename / byte count / configured URL. Next reporter
on this code path produces a support bundle that contains the answer.
The settings-table passthrough auto-captured everything in `settings` (with
sensitive-key redaction), but features storing config in dedicated tables
were invisible. Triaging recent OIDC / 2FA / group bugs and the X1C slicer
investigation needed data that wasn't in the bundle.
New blocks in _collect_support_info:
- auth: OIDC providers (cleartext names, no secrets), TOTP / OTP /
API-key / long-lived-token / group counts
- library: file / folder / external / trash / makerworld totals
- inventory: spool + k-profile counts
- queue: pending count, oldest pending age
- maintenance: items total + enabled
- integrations.github_backup: providers used + recent failures
- integrations.slicer_api: enabled, URL source, reachability ping
- per-printer obico_enabled flag
Plus three smaller fixes caught testing against a real bundle:
- mqtt_broker no longer leaks (broker keyword added)
- virtual_printer_tailscale_auth_key no longer leaks (auth_key keyword
+ tskey- value-prefix safety net for future Tailscale settings)
- slicer-API reachability check now mirrors the route's three-level URL
precedence (DB → env var → default), instead of only looking at the
DB setting. Previously returned null for every installation running
the sidecar via env var or default port — i.e. most of them.
Restarting Bambuddy mid-print misfired the plate-check + archive flow.
The is_new_print guard treated _previous_gcode_state=None → RUNNING as
a transition, but None just means we haven't seen any prior state yet —
catch-up from a printer that was already running, not a fresh start.
Add `_previous_gcode_state is not None` to the guard. _was_running still
flips on unconditionally, so completion detection is unchanged. 3 tests
that asserted the buggy behavior now seed an explicit prior state; new
regression test pins the contract for the reporter's exact scenario.
External scan hung on a 1200-subdir NAS because (1) every STL crashed
with TypeError ('str / str') inside generate_stl_thumbnail and (2)
thumbnail generation ran synchronously per file, so the FE timed out
before db.commit() and nothing was persisted.
stl_thumbnail.py now coerces inputs to Path defensively, and
scan_external_folder defers STL thumbnail generation to a background
asyncio task that opens its own session and processes each file
post-commit. Subdirs appear in the sidebar immediately; thumbnails
backfill over the next seconds/minutes.