POST /updates/apply short-circuits (returning a payload without the per-branch
keys like is_windows_installer) when the module-global _update_status is
downloading/installing. A prior test leaving an apply flow mid-update made
test_apply_update_windows_installer_rejection hit that guard instead of the
Windows branch — an order-dependent flake that passed locally but failed on the
sharded CI run with KeyError: 'is_windows_installer'. Add an autouse fixture
resetting _update_status to idle before each TestUpdatesAPI test.
On a dual-nozzle H2C with a Filament Track Switch, a queued print targeting
one nozzle fed a same-type wrong-colour spool: the backend mapping
(_match_filaments_to_slots) hard-filtered candidate trays to the requested
extruder, excluding the correct spool loaded in the other nozzle's AMS — which
the FTS can route across. Confirmed from the reporter's captures: same model
mapped to AMS-A slot 2 (black) on the left nozzle but AMS-B slot 3 (red) on the
right. The #1162 FTS-skip existed only in the frontend mapping, never the
queue-dispatch path.
Read fila_switch.installed in _compute_ams_mapping_for_printer and skip the
per-nozzle filter when an FTS is present. Single-nozzle printers are unaffected
(no nozzle_id in the 3MF, no FTS). Regression tests in TestFtsNozzleBypass.
The 40x30 mm box label rendered its QR too densely for low-res thermal
printers — the modules bled together and wouldn't scan. Two causes: the QR
was 20% of inner width (~7.5 mm on the narrowest template, half of the
others) and used ERROR_CORRECT_M. Fix adaptively so all templates benefit:
give the roomy-layout QR a 12 mm minimum size (box_40x30 -> 12 mm, ~3.5
dots/module at 203 dpi) and switch label QRs to ERROR_CORRECT_L (same
payload, chunkier modules; a label needs no M-level recovery). Keep the
quiet-zone border at 2 — the size+L gains suffice without risking scans.
Also add a Monochrome (black & white printer) option to the label dialog:
drops the colour swatch (a useless grey block on B&W) and widens the text;
the hex-code line still carries the colour. Threaded through the renderer,
route, API client, and modal, with translations in all 11 locales.
check_queue() read the plate-clear setting with _get_bool_setting(default=True),
but SettingsSchema.require_plate_clear defaults False and the whole frontend
treats a missing value as off. Since _get_bool_setting returns its default when
no DB row exists, installs that never saved the setting enforced the plate-clear
gate the UI showed as disabled — FINISH-state printers never dispatched and no UI
control existed to clear awaiting_plate_clear. Read the setting with default=False
so the enforced behavior matches the schema and the toggle. Both defaults shipped
together in #752; this aligns them.
get_network_interfaces() only sent Windows to the psutil path; macOS fell into
the Linux ioctl branch, whose SIOCGIFADDR/SIOCGIFNETMASK ioctls are Linux-only.
macOS/BSD have fcntl but different ioctl numbers, so every call raised OSError
and the function returned an empty list — the VP bind-interface dropdown showed
nothing. Route all non-Linux platforms through the cross-platform psutil path.
External cameras in HTTP-snapshot mode failed to load with a repeating
"connection lost" when the endpoint served PNG/WebP/BMP stills instead of
JPEG (common on IP cameras and reverse-proxied snapshot URLs). The URL
rendered fine directly in a browser, but Bambuddy's MJPEG stream wraps
every part in a hard-coded Content-Type: image/jpeg boundary, so a
non-JPEG payload labelled as JPEG made the browser reject the frame and
tear down the whole multipart/x-mixed-replace stream.
_capture_snapshot now transcodes non-JPEG stills to JPEG via OpenCV
(already a dependency). Genuine JPEG snapshots keep a byte-for-byte fast
path; truly undecodable responses (HTML error pages, auth redirects) fall
back to the previous raw-return behaviour with a single clear warning
instead of a per-frame log flood.
Native (non-Docker) installs launched uvicorn without --loop asyncio, so
uvicorn[standard] auto-selected uvloop. uvloop's SSL layer drops
already-received but still-buffered data when the client closes the data
connection without a TLS close_notify while the reader is flow-control
paused on slow storage. cmd_STOR writes each chunk to disk inside the read
loop, so a slow consumer falls behind, the tail is lost, read() returns a
clean EOF, and the loop exits with no exception -- the server acked 226 for
a file it truncated itself, then archived, queued, and forwarded the corrupt
3MF to the real printer.
Fix in two independent layers:
1. Remove the trigger: add --loop asyncio to every native launch path,
matching the Dockerfile -- deploy/bambuddy.service, install/install.sh
(systemd + launchd), spoolbuddy/install/install.sh, the Windows NSSM
service, README, CONTRIBUTING dev command.
2. Defense in depth (loop-independent): cmd_STOR now validates that a
received .3mf opens as a ZIP (reads the central directory, no
decompression) before replying 226. A truncated/corrupt file is dropped
and answered with 426, and on_file_received never runs -- so a broken
upload surfaces as an immediate slicer-side send error instead of being
archived and pushed to the printer. Scoped to .3mf; other filetypes pass
through unchanged.
PROJECTS_CREATE/UPDATE/DELETE were in _APIKEY_DENIED_PERMISSIONS with no
entry in _APIKEY_SCOPE_BY_PERMISSION, so every project mutation returned a
generic 403 for any API key regardless of granted permissions -- the same
regression class as archives (#1888) and library (#1832).
Add a per-key can_manage_projects scope. Project routes gate on plain
PROJECTS_* (no OWN/ALL split), so all three CRUD permissions map to the one
scope; membership edits (add-archives) gate on PROJECTS_UPDATE and are
covered. PROJECTS_READ is unchanged (already under can_read_status).
Column defaults TRUE for new keys; existing rows backfill to FALSE so the
upgrade never silently widens scope. Migration is BOOLEAN (SQLite + Postgres
safe), verified on fresh SQLite and Postgres 17. Bundled SpoolBuddy kiosk key
set to False. Settings API-key UI gets a Manage Projects toggle + Projects
badge; 11-locale i18n. RBAC scope matrix + drift guards extended.
Auto-drying stopped manually started (and pre-restart) AMS drying cycles
after exactly 30 minutes. The already-drying branch in _check_auto_drying()
applied a humidity-based auto-stop despite its own "track but don't stop"
comment, and the humidity re-check is unreliable: RH drops steeply in heated
air, so the sensor reads ~15-20% within minutes of the dryer starting even
with saturated filament. humidity <= threshold was thus effectively always
true, and the _min_drying_seconds=1800 floor pinned the stop to the 30-minute
mark. This also truncated Bambuddy's own preset-duration dries.
Remove the humidity-based early-stop entirely: a running dry now runs to its
configured duration (firmware stops it). Scheduling stops (print priority,
queue no longer needing the dry) are unchanged via _stop_drying(). Drop the
now-unused _min_drying_seconds.
The print-queue "auto off after this job" trigger used a second, inline
auto-off implementation (main.py, print_scheduler.py, print_queue.py)
that hardcoded wait_for_cooldown(50C, 600s) — ignoring each plug's
configured off_delay_mode / off_delay_minutes / off_temp_threshold — and
ignored the return value, powering off on the 600s timeout regardless of
print state. A print that failed and was reprinted from the touchscreen
got its power cut mid-print. The inline tasks were also uncancellable, so
a reprint couldn't abort a pending off.
Consolidate all three into SmartPlugManager.schedule_off_after_queue_job,
which schedules via the plug's configured strategy (shared with
on_print_complete through _schedule_off_per_mode) and is cancellable via
_pending_off. Add printer_manager.is_print_active() and guard the actual
power-off in _delayed_off and _temp_based_off so no path cuts power on a
loaded print. Move the on_print_start cancellation ahead of the auto_on
gate so a reprint always aborts a pending off.
DELETE /api/v1/archives/{id} rejected every API key with 403
"API keys cannot be used for administrative operations", regardless of
the print's owner or the key's scopes. ARCHIVES_DELETE_ALL/_OWN (and the
create/update variants) were on the denylist and absent from the scope
allowlist, so require_ownership_permission fell through to the generic
admin-denied 403 — the whole archive-management surface was unreachable
for API keys. Same regression class as the #1832 library/maintenance
carve-outs.
Add a can_manage_archives per-key scope: ARCHIVES_CREATE, ARCHIVES_
UPDATE_OWN/_ALL and ARCHIVES_DELETE_OWN/_ALL move from the denylist to
the allowlist under it (OWN and ALL fold into the same scope, matching
can_manage_library). ARCHIVES_PURGE stays admin-only — it drops the
print's Quick Stats contribution, mirroring LIBRARY_PURGE. Column
defaults TRUE for UI-created keys; existing rows backfill to FALSE so the
upgrade never silently widens scope. Bundled SpoolBuddy kiosk key stays
minimally scoped (False). Migration is dialect-agnostic and verified on
fresh SQLite and Postgres 17.
Adds the Settings API-key toggle + badge (11-locale i18n) and extends the
RBAC scope matrix to cover all five archive-management permissions.
Three bugs on the same PLA-model + PVA-support flow, discovered in
sequence:
(A) substitute_unused_plate_filaments inspected only object geometry
(per-object extruder metadata + paint_color triangles) so a support-
only slot was silently treated as "unused" and the user's PVA profile
got overwritten with slot 1's PLA.
(B) _extract_filament_info stripped filament_is_support==1 entries,
hiding PVA from unsliced source archive cards even when the project
explicitly configured it.
(C) --load-settings is authoritative over the source's project_settings.
config, and Bambu's shipped process presets ship enable_support=0
(supports are a per-print decision, not per-quality). So even with
(A) fixed, the sliced output had supports disabled and the PVA slot
loaded but never consumed. Inverts BambuStudio GUI's semantics where
the project overrides the preset.
Fixes:
- New extract_support_filament_slots_from_3mf reads enable_support +
support_filament + support_interface_filament from project_settings.
config; substitute_unused_plate_filaments unions it into the geometry-
derived set.
- _extract_filament_info returns all configured filament types + colours.
- New _patch_process_support_settings overlays four fields (enable_
support, support_filament, support_interface_filament, support_type)
from the source 3MF onto the picked process preset JSON before
--load-settings sees it. Deliberately targeted to what fixes#1881
without widening to a full project-over-preset merge.
Reporter (H2C + macOS 26.5.1 + BS 2.8.0.50): after every Mac sleep/wake
cycle, Bambu Studio couldn't see the VP or connect to it. Only fix was
quit BS + reboot Bambuddy. The physical printer's own cloud/LAN link
recovered in ~5 s from the same sleep — the delta was in VP session
handling.
Log evidence (bug-report-assets/logs/ddf1ede75df045cd94ad223d0f08f88a):
- 14:04:06 healthy `1Hz status push: 60 pushes/min to :54698`
- 14:04:06 → 14:09:16: five minutes of SSDP-only, no push summary for
:54698, no OSError, no disconnect line
- 14:09:16: new source port :54861 connects and authenticates fine —
the server was not rejecting reconnects
- 14:10:17 first DEBUG line: `MQTT drain timeout for
device/…/report — client may be busy` — smoking gun
Root cause: `_publish_to_report:1149` caught `asyncio.wait_for(drain,
timeout=5)` TimeoutError at DEBUG and returned silently. TimeoutError
is not OSError, so the push loop's `except OSError` at :441 never saw
it — the zombie writer sat in self._clients until the kernel's default
TCP keepalive detected the dead peer (Linux default: ~2 h 11 min).
Two hunks:
1. `_publish_to_report`: on drain TimeoutError, close the writer (best
effort, catch Exception so an already-broken close() doesn't mask
the raise) and raise BrokenPipeError, which IS OSError. Push loop
evicts on the same tick.
2. `_handle_client`: after SO_KEEPALIVE=1, set TCP_KEEPIDLE=60,
TCP_KEEPINTVL=15, TCP_KEEPCNT=4 — dead-peer detection in ~2 min
instead of ~2 h. `getattr(socket, ...)` guards keep it cross-
platform (macOS uses TCP_KEEPALIVE not TCP_KEEPIDLE, other kernels
may not expose all three — skip whichever is missing).
What I got wrong first pass and corrected on log-read: hypothesised
"missing MQTT session takeover on same client_id". Wrong. _handle_connect
parses the protocol client_id but discards it (assignment commented out
at :762), and self._clients is keyed on `f"{addr[0]}:{addr[1]}"` (socket
peer), so every reconnect gets a distinct key. No takeover race exists.
The log fixed this: the "not seen" symptom is BS-side (macOS UDP
receive after sleep + BS holding the pre-sleep socket state), but the
server-side amplifier was the zombie writer.
Non-proxy VP mode hardcoded the camera-passthrough TCPProxy to
listen_port=322 / target_port=322 regardless of the target printer's
model. That port is correct for RTSPS models (X1/X2/H2/P2S), but A1 /
A1 Mini / P1P / P1S use Bambu's proprietary chamber-image protocol on
port 6000. Result: A1/P1 targets got a 322 listener with no upstream,
OrcaSlicer Liveview failed with [2:-10061], BambuStudio's camera button
timed out.
Reporter confirmed a raw socat forwarder `<VP-IP>:6000 → <P1S-IP>:6000`
restored the stream — the target camera works, the VP just wasn't
publishing it.
Proxy mode was unaffected because SlicerProxyManager already opens 6000
(nominally file-transfer; Bambu reuses the port for chamber-image), so
the passthrough coincidentally works there.
Fix: read the target's model from
`printer_manager.get_client(target_id).model` at the same point we read
target_ip, then use `get_camera_port(target_model)` — the same source of
truth as routes/camera.py — to pick 322 or 6000. Model comes from the
physical printer, NOT self.model (the VP's spoofed identity has no
bearing on how the real device serves its camera).
Renamed the log tag from "RTSP" to f"Camera-{camera_port}" so support
bundles show which protocol the VP is fronting at a glance. Kept the
_rtsp_proxy attribute name to keep the diff tight; the block comment
spells out that it doubles as chamber-image passthrough on A1/P1.
A1 Mini firmware skips stg_cur=22 entirely, so the finish-photo fallback
fires at gcode_state=FINISH — which runs AFTER Bambu Studio has already
executed the user's End G-code. Users with SwapMod plate-swap injected
into End G-code always got a photo of the swapped (empty) plate.
Add a layer_num >= total_layer_num edge trigger in _parse_print_data so
the pre-capture fires the moment the last object layer completes, on
every printer variant. Guarded by the existing _finish_photo_captured
one-shot so stage-22 and FINISH-state hooks become no-ops for the same
print — no framing regression on AMS printers without custom end G-code.
usage_tracker's tray-switch split has never had a Spoolman peer.
An AMS same-material runout switch mid-print charged the whole slot
to the origin spool via the (via tag) path and double-credited the
backup via remain-delta — origin exceeded initial_weight.
Extract the segment-math into utils/tray_split.compute_tray_split_grams
and call it from both writers so the two inventory backends attribute
mid-print switches identically. spoolman_tracking gains
_report_spool_usage_split_by_tray_changes; the Path 2 remain-delta
fallback now skips trays the split path covered, killing the
double-count.
Carve MAINTENANCE_CREATE/UPDATE/DELETE out of the admin denylist so
HA automations can log "cleaned nozzle" / reset a counter via API key
without granting broader printer control. Follows the same shape as
can_manage_library and can_manage_inventory: new column, allowlist
entry, UI checkbox, wiki row, RBAC test coverage.
Distinct backfill: these perms were EXPLICITLY denied for every API
key before this change (no existing integration relies on them), so
existing rows migrate to FALSE — no silent scope widening on upgrade.
New keys default to TRUE, matching the safe-on-by-default pattern.
Bundled SpoolBuddy kiosk key gets False explicitly (kiosk doesn't need it).
get_setting_detail and delete_setting were hitting
/v1/iot-service/api/slicer/setting/{id} without the version query
parameter Bambu Cloud requires — every call returned HTTP 400
"field 'version' is not set". The sibling plural GET
(get_slicer_settings) has always sent it; the comment above
_SLICER_API_VERSION documents the contract for the endpoint subtree.
Missed when the placeholder landed in the 2026-05-12 compliance rework.
Downstream effect: slicer_filament_resolver.resolve_slicer_filament's
PFUS branch swallowed the 400, fell through to normalize_slicer_filament,
and caller inventory.py generic-material-fell-back tray_info_idx to
GFL99/GFG99. BambuStudio's AMS panel reads the printer's tray_info_idx
echo, so the user saw "Generic PLA" instead of the custom cloud preset.
Masked for 50 days by two rescue paths in the caller: prior-slot
tray_info_idx reuse, and stored spool_k_profile → live state.kprofiles
realign. Reporter's spool 54 → tray 2 assign had neither.
Adjacent surfaces also fixed by the same two-line change: the delete
cloud preset UI route, the whole update_setting flow (get_setting_detail
→ delete_setting → POST), preset_resolver's cloud branch, and three
UI-facing cloud.py routes that fetch setting detail.
get_setting_detail also includes the truncated response body in the
raised BambuCloudError so the next contract change is self-diagnostic
from support-bundle logs.
Symptom: user queued a batch of 10 prints, pressed Cancel on a pending
row, the print started anyway. Repeated consecutively. Support bundle
also showed 15x "sqlite3.OperationalError: database is locked" from the
sensor history recorder in the same 8-minute window.
Root cause is a check-then-act race in _start_print. check_queue takes
a snapshot of pending items, then _start_print does FTP delete + FTP
upload (5-30s) before the unconditional item.status = "printing";
db.commit() at line 2792. /cancel commits status='cancelled' in a
separate session during that window; the scheduler's stale in-memory
write overwrites it and start_print ships. The lock-contention finding
is the same shape from a different angle: _start_print did
await db.flush() at line 2555 (after item.archive_id set + library_file
delete) which opens the SQLite WAL writer lock and holds it through
the FTP upload, queueing every concurrent writer behind it including
the user's own cancel commit.
Three guards layered:
1) Atomic CAS at the pending->printing transition. UPDATE print_queue
SET status='printing', started_at=NOW() WHERE id=:id AND
status='pending'. rowcount==0 means user won; log abort, best-effort
delete_file_async the file we just FTP'd up so it doesn't leak into
the printer's BambuStudio file picker, send queue_item_failed WS
event with reason="cancelled_mid_dispatch", return without calling
printer_manager.start_print.
2) Early db.refresh(item) + bail right after the printer connectivity
check. Saves the wasted FTP upload when the row was already
cancelled before _start_print resumed. Defense in depth; guard 1
catches the same case at the CAS point.
3) flush -> commit before the FTP block. The library-file-to-archive
promotion's writes commit cleanly, WAL writer lock releases, sensor
history and concurrent cancels stop queueing behind the scheduler.
The flush-not-commit pattern was rolling back a pointer to an
already-committed archive row, so the new behaviour matches reality
(archive committed, pointer committed, FTP unblocked).
New scheduler stage that heats the bed (and the chamber, on supported
printers) and holds at temperature before each queued print starts —
the heat-soak engineering filaments need for adhesion and warp
control. Bambuddy waits between FTP upload and start_print, so the
soak runs while the printer is otherwise idle. M191 is silently
ignored by Bambu firmware, so doing this at the orchestration layer
is the only place it works.
Resolution order at dispatch:
1. PrintQueueItem.preheat_override ∈ {inherit, on, off}.
'off' skips entirely; 'inherit' falls back to the global
preheat_enabled toggle; 'on' forces the stage even when the
global is off.
2. chamber_target = item.preheat_chamber_target_override
?? max(filament_map[normalize(t.tray_type)] for loaded slots)
?? 0.
Mixed PA+PLA picks PA's 50 (max-across-slots — PA's chamber
requirement is binding, PLA doesn't suffer being warm). PLA-only
derives 0 and skips the chamber phase automatically.
3. Three hardware tiers for chamber heat:
- Active chamber heater (H2C/H2D/H2D Pro/H2S/X2D/X1E) → M141 +
chamber-sensor wait
- Chamber sensor only (X1C/P2S) → no M141, passive bed-radiation
wait with hard max-wait cap
- No chamber sensor (P1S/P1P/A1/A1 Mini) → bed + soak timer only
4. Airduct flap (H2C/H2D/H2D Pro/H2S/X2D/P2S) auto-switches to
match the chamber target — heating mode for engineering
filaments, cooling mode for PLA. Bambu firmware does NOT
auto-switch the flap with M141, so without this an ABS print
on a previously-cooling flap fights the open exhaust, and a
PLA print on a previously-hot flap recirculates ABS heat.
Idempotent: only fires set_airduct_mode when current ≠ desired.
Settings → Workflow → Queue & Dispatch → Preheat & Heat Soak card:
master enable toggle (default off — disabled installs see no change),
per-filament chamber-target editor (replaces a single global int that
shipped in the first cut and couldn't serve PA + PLA in the same
config), preheat_max_wait_seconds, preheat_soak_seconds. The Print
Options panel in PrintModal gets a Preheat sub-section with the
tri-state Inherit/On/Off control and an optional chamber-target
override input.
DB migration: PrintQueueItem gains preheat_override VARCHAR(10)
DEFAULT 'inherit' and preheat_chamber_target_override INTEGER NULL.
Idempotent via _safe_execute. Existing rows behave exactly as before
the migration.
Best-effort throughout: printer drops, refused M141 or set_airduct,
missing bed temp, lost MQTT state mid-wait all log and return cleanly.
Normal upload + start path runs after this returns regardless.
The HMS error modal had three compounding bugs that surfaced when a
user forced a wrong-plate HMS (0500_8051) and tried to dispatch the
per-fault actions.
(1) IGNORE_RESUME did not ignore. Bambuddy redirected the action on
state=PAUSE to a plain `resume` command, citing a #1830 verdict that
BambuStudio's "err-bearing shape" was firmware-silently-rejected.
BambuStudio source disagrees: DeviceErrorDialog.cpp:600 dispatches
IGNORE_RESUME via command_hms_ignore, whose wire shape is
{command:"ignore", err:"<decimal>", param:"reserve", job_id:...}.
That's a distinct command from `resume` — the firmware suppresses
the next re-check AND auto-resumes in one operation. Plain resume
means "re-check normally", which is exactly why the wrong-plate
detection re-fired 1-2 s after the user clicked Ignore. The #1830
"err-bearing shape rejected" test almost certainly sent the err as
a hex shortcode; BambuStudio passes std::to_string(int m_error_code)
i.e. the DECIMAL form, which is what the firmware matches against.
(2) Action buttons read as inert badges. The button className used
`hover:${buttonHoverColor}` — a template-literal interpolation
Tailwind's JIT scanner can't see as a literal string, so the
per-severity hover utility never reached the compiled CSS. Same
bg/text color as the severity badge above and no border made it
read as another label. No disabled state and no spinner during the
2.5 s ack wait left clicks sitting silently inert.
(3) Ack-detection 502'd on legitimate ack. The route compared
(gcode_state, hms_errors-len) before vs after publish; wrong-plate
re-pause round-tripped both fields to their pre-publish values
inside the 2.5 s window → false 502 even though the firmware fully
ack'd. PROBLEM_SOLVED_RESUME working but IGNORE_RESUME 502'ing on
the same fault was the same race resolving differently.
Fixes:
bambu_mqtt.py — new hms_ignore_command() publishes the BambuStudio
shape; existing hms_ignore(persistent) renamed to hms_idle_ignore
(unchanged shape, used by NO_REMINDER_NEXT_TIME per
DeviceErrorDialog.cpp:588). Dispatch routes IGNORE_RESUME,
IGNORE_NO_REMINDER_NEXT_TIME, and DONT_REMIND_NEXT_TIME to
hms_ignore_command (BambuStudio routes all three to the same
command_hms_ignore — the "don't remind" half is the firmware's
job). NO_REMINDER_NEXT_TIME stays on hms_idle_ignore type=0. Hex →
decimal err conversion at the helper layer with a defensive
fallback. job_id=None → empty string (matches BambuStudio's
std::string default).
HMSErrorModal.tsx — getSeverityInfo loses the dead buttonHoverColor
field. Action button uses static
`bg-white/10 hover:bg-white/20 active:bg-white/30 text-white
border border-white/20`, wires
`disabled={!hasPermission||mutation.isPending}`, and renders
`<Loader2/>` only on the button whose (action,print_error) matches
mutation.variables.
printers.py — ack-detection probes `client._last_message_time`
(bumped on every MQTT push regardless of payload) rather than
diffing state fields. The pushall that follows every command
guarantees a fresh push lands inside the 2.5 s window on any
healthy printer; only firmware-silent-drop leaves the timestamp
untouched, which is the 502 path #1830 wanted.
Two compounding bugs let an H2C-bound filament land in slot 1 of an A1
slice silently. (1) `_slicer_rejection_message` discarded the actual CLI
diagnostic - `filament preset Generic PLA @BBL H2C (slot 1) is not
compatible with printer Bambu Lab A1 0.4 nozzle.` - when the sidecar's
headline error_string was Bambu Studio's catch-all
`The input preset file is invalid and can not be parsed.` placeholder.
The real reason was in the stdout `[error] run NNNN:` line, trimmed off
before reaching the SliceJob's error_detail. (2) `pickFilamentForSlot`
used a soft `-100` mismatch penalty rather than a hard skip, leaving
the "never auto-fill an incompatible preset while a compatible one
exists" contract implicit. The unused-slot substitution in
`substitute_unused_plate_filaments` then propagated whatever slot 1
held across every unused slot - one bad pick poisoned the array.
(1) Mine `[error] <msg>` (with or without `run NNNN:`) from the full
pre-trim response; substitute the placeholder, keep meaningful
headlines. (2) Partition candidates into compatible/unknown vs
mismatch; prefer compatible whenever the bucket is non-empty, fall
back to mismatch only on graceful-degrade. Picker helpers moved out
of `SliceModal.tsx` into `utils/slicePresetPicker.ts` so the modal
file stays component-only (react-refresh lint).
Administrators system group sync
- Fresh installs already bootstrap with ALL_PERMISSIONS, so they always have
every permission. Upgrades previously only got what one-off backfill blocks
in seed_default_groups() explicitly listed (library:purge, archives:purge,
the OWN/ALL read-flag block, orca_cloud:auth, pipelines:*). Any Permission
enum member added without a matching block silently stayed missing on
existing admin rows. The most recent gap was printer_sensor_history:read
(Sensor History charts returned 403 for upgraded admins).
- seed_default_groups() now syncs Administrators to ALL_PERMISSIONS on every
startup: append every Permission value that isn't already on the row.
Additive only -- hand-added custom permissions are preserved.
- The pure-admin one-off backfills (library:purge / archives:purge block,
the OWN/ALL + orca_cloud:auth + legacy-read-flag block, the Administrators
branch of the pipeline backfill) are retired since the sync subsumes
them. Non-admin backfills (Operators / Viewers OWN-tier reads, Operators
orca_cloud:auth, pipelines for non-admin groups, makerworld:*, clear_plate
cross-group adders) are untouched.
- Tests: test_administrators_printer_sensor_history_read_backfilled
(regression for the reported gap),
test_administrators_sync_covers_every_current_permission (generic
invariant -- any future new permission lands on admin without needing
a one-off test), test_administrators_sync_is_additive_only (custom
permissions preserved). 12/12 backfill-migration + 102/102 broader
permission tests green; ruff clean.
Pipelines runs dashboard
- PipelineRunsPage.tsx: the Pipeline / Status / Target filter row's three
native <select> elements are replaced with a bambu-themed FilterDropdown
(button trigger, floating menu, optgroup-style headers for the Target
picker, hover + selected states with a check mark, closes on outside
click and Escape). Same value/onChange contract -- visual only.
- SlicerPipelinesPanel.tsx: wrap list?.pipelines ?? [] in useMemo so the
reference is stable when the data is stable. Fixes the
react-hooks/exhaustive-deps warning where the inline fallback returned
a fresh empty array every render, invalidating both downstream useMemo
caches (target-options + filtered-pipelines list).
The 40x30 mm box label rendered its QR too densely for low-res thermal
printers — the modules bled together and wouldn't scan. Two causes: the QR
was 20% of inner width (~7.5 mm on the narrowest template, half of the
others) and used ERROR_CORRECT_M. Fix adaptively so all templates benefit:
give the roomy-layout QR a 12 mm minimum size (box_40x30 -> 12 mm, ~3.5
dots/module at 203 dpi) and switch label QRs to ERROR_CORRECT_L (same
payload, chunkier modules; a label needs no M-level recovery). Keep the
quiet-zone border at 2 — the size+L gains suffice without risking scans.
Also add a Monochrome (black & white printer) option to the label dialog:
drops the colour swatch (a useless grey block on B&W) and widens the text;
the hex-code line still carries the colour. Threaded through the renderer,
route, API client, and modal, with translations in all 11 locales.
check_queue() read the plate-clear setting with _get_bool_setting(default=True),
but SettingsSchema.require_plate_clear defaults False and the whole frontend
treats a missing value as off. Since _get_bool_setting returns its default when
no DB row exists, installs that never saved the setting enforced the plate-clear
gate the UI showed as disabled — FINISH-state printers never dispatched and no UI
control existed to clear awaiting_plate_clear. Read the setting with default=False
so the enforced behavior matches the schema and the toggle. Both defaults shipped
together in #752; this aligns them.
get_network_interfaces() only sent Windows to the psutil path; macOS fell into
the Linux ioctl branch, whose SIOCGIFADDR/SIOCGIFNETMASK ioctls are Linux-only.
macOS/BSD have fcntl but different ioctl numbers, so every call raised OSError
and the function returned an empty list — the VP bind-interface dropdown showed
nothing. Route all non-Linux platforms through the cross-platform psutil path.
External cameras in HTTP-snapshot mode failed to load with a repeating
"connection lost" when the endpoint served PNG/WebP/BMP stills instead of
JPEG (common on IP cameras and reverse-proxied snapshot URLs). The URL
rendered fine directly in a browser, but Bambuddy's MJPEG stream wraps
every part in a hard-coded Content-Type: image/jpeg boundary, so a
non-JPEG payload labelled as JPEG made the browser reject the frame and
tear down the whole multipart/x-mixed-replace stream.
_capture_snapshot now transcodes non-JPEG stills to JPEG via OpenCV
(already a dependency). Genuine JPEG snapshots keep a byte-for-byte fast
path; truly undecodable responses (HTML error pages, auth redirects) fall
back to the previous raw-return behaviour with a single clear warning
instead of a per-frame log flood.
Native (non-Docker) installs launched uvicorn without --loop asyncio, so
uvicorn[standard] auto-selected uvloop. uvloop's SSL layer drops
already-received but still-buffered data when the client closes the data
connection without a TLS close_notify while the reader is flow-control
paused on slow storage. cmd_STOR writes each chunk to disk inside the read
loop, so a slow consumer falls behind, the tail is lost, read() returns a
clean EOF, and the loop exits with no exception -- the server acked 226 for
a file it truncated itself, then archived, queued, and forwarded the corrupt
3MF to the real printer.
Fix in two independent layers:
1. Remove the trigger: add --loop asyncio to every native launch path,
matching the Dockerfile -- deploy/bambuddy.service, install/install.sh
(systemd + launchd), spoolbuddy/install/install.sh, the Windows NSSM
service, README, CONTRIBUTING dev command.
2. Defense in depth (loop-independent): cmd_STOR now validates that a
received .3mf opens as a ZIP (reads the central directory, no
decompression) before replying 226. A truncated/corrupt file is dropped
and answered with 426, and on_file_received never runs -- so a broken
upload surfaces as an immediate slicer-side send error instead of being
archived and pushed to the printer. Scoped to .3mf; other filetypes pass
through unchanged.
PROJECTS_CREATE/UPDATE/DELETE were in _APIKEY_DENIED_PERMISSIONS with no
entry in _APIKEY_SCOPE_BY_PERMISSION, so every project mutation returned a
generic 403 for any API key regardless of granted permissions -- the same
regression class as archives (#1888) and library (#1832).
Add a per-key can_manage_projects scope. Project routes gate on plain
PROJECTS_* (no OWN/ALL split), so all three CRUD permissions map to the one
scope; membership edits (add-archives) gate on PROJECTS_UPDATE and are
covered. PROJECTS_READ is unchanged (already under can_read_status).
Column defaults TRUE for new keys; existing rows backfill to FALSE so the
upgrade never silently widens scope. Migration is BOOLEAN (SQLite + Postgres
safe), verified on fresh SQLite and Postgres 17. Bundled SpoolBuddy kiosk key
set to False. Settings API-key UI gets a Manage Projects toggle + Projects
badge; 11-locale i18n. RBAC scope matrix + drift guards extended.
Auto-drying stopped manually started (and pre-restart) AMS drying cycles
after exactly 30 minutes. The already-drying branch in _check_auto_drying()
applied a humidity-based auto-stop despite its own "track but don't stop"
comment, and the humidity re-check is unreliable: RH drops steeply in heated
air, so the sensor reads ~15-20% within minutes of the dryer starting even
with saturated filament. humidity <= threshold was thus effectively always
true, and the _min_drying_seconds=1800 floor pinned the stop to the 30-minute
mark. This also truncated Bambuddy's own preset-duration dries.
Remove the humidity-based early-stop entirely: a running dry now runs to its
configured duration (firmware stops it). Scheduling stops (print priority,
queue no longer needing the dry) are unchanged via _stop_drying(). Drop the
now-unused _min_drying_seconds.
The print-queue "auto off after this job" trigger used a second, inline
auto-off implementation (main.py, print_scheduler.py, print_queue.py)
that hardcoded wait_for_cooldown(50C, 600s) — ignoring each plug's
configured off_delay_mode / off_delay_minutes / off_temp_threshold — and
ignored the return value, powering off on the 600s timeout regardless of
print state. A print that failed and was reprinted from the touchscreen
got its power cut mid-print. The inline tasks were also uncancellable, so
a reprint couldn't abort a pending off.
Consolidate all three into SmartPlugManager.schedule_off_after_queue_job,
which schedules via the plug's configured strategy (shared with
on_print_complete through _schedule_off_per_mode) and is cancellable via
_pending_off. Add printer_manager.is_print_active() and guard the actual
power-off in _delayed_off and _temp_based_off so no path cuts power on a
loaded print. Move the on_print_start cancellation ahead of the auto_on
gate so a reprint always aborts a pending off.
DELETE /api/v1/archives/{id} rejected every API key with 403
"API keys cannot be used for administrative operations", regardless of
the print's owner or the key's scopes. ARCHIVES_DELETE_ALL/_OWN (and the
create/update variants) were on the denylist and absent from the scope
allowlist, so require_ownership_permission fell through to the generic
admin-denied 403 — the whole archive-management surface was unreachable
for API keys. Same regression class as the #1832 library/maintenance
carve-outs.
Add a can_manage_archives per-key scope: ARCHIVES_CREATE, ARCHIVES_
UPDATE_OWN/_ALL and ARCHIVES_DELETE_OWN/_ALL move from the denylist to
the allowlist under it (OWN and ALL fold into the same scope, matching
can_manage_library). ARCHIVES_PURGE stays admin-only — it drops the
print's Quick Stats contribution, mirroring LIBRARY_PURGE. Column
defaults TRUE for UI-created keys; existing rows backfill to FALSE so the
upgrade never silently widens scope. Bundled SpoolBuddy kiosk key stays
minimally scoped (False). Migration is dialect-agnostic and verified on
fresh SQLite and Postgres 17.
Adds the Settings API-key toggle + badge (11-locale i18n) and extends the
RBAC scope matrix to cover all five archive-management permissions.
Three bugs on the same PLA-model + PVA-support flow, discovered in
sequence:
(A) substitute_unused_plate_filaments inspected only object geometry
(per-object extruder metadata + paint_color triangles) so a support-
only slot was silently treated as "unused" and the user's PVA profile
got overwritten with slot 1's PLA.
(B) _extract_filament_info stripped filament_is_support==1 entries,
hiding PVA from unsliced source archive cards even when the project
explicitly configured it.
(C) --load-settings is authoritative over the source's project_settings.
config, and Bambu's shipped process presets ship enable_support=0
(supports are a per-print decision, not per-quality). So even with
(A) fixed, the sliced output had supports disabled and the PVA slot
loaded but never consumed. Inverts BambuStudio GUI's semantics where
the project overrides the preset.
Fixes:
- New extract_support_filament_slots_from_3mf reads enable_support +
support_filament + support_interface_filament from project_settings.
config; substitute_unused_plate_filaments unions it into the geometry-
derived set.
- _extract_filament_info returns all configured filament types + colours.
- New _patch_process_support_settings overlays four fields (enable_
support, support_filament, support_interface_filament, support_type)
from the source 3MF onto the picked process preset JSON before
--load-settings sees it. Deliberately targeted to what fixes#1881
without widening to a full project-over-preset merge.
Reporter (H2C + macOS 26.5.1 + BS 2.8.0.50): after every Mac sleep/wake
cycle, Bambu Studio couldn't see the VP or connect to it. Only fix was
quit BS + reboot Bambuddy. The physical printer's own cloud/LAN link
recovered in ~5 s from the same sleep — the delta was in VP session
handling.
Log evidence (bug-report-assets/logs/ddf1ede75df045cd94ad223d0f08f88a):
- 14:04:06 healthy `1Hz status push: 60 pushes/min to :54698`
- 14:04:06 → 14:09:16: five minutes of SSDP-only, no push summary for
:54698, no OSError, no disconnect line
- 14:09:16: new source port :54861 connects and authenticates fine —
the server was not rejecting reconnects
- 14:10:17 first DEBUG line: `MQTT drain timeout for
device/…/report — client may be busy` — smoking gun
Root cause: `_publish_to_report:1149` caught `asyncio.wait_for(drain,
timeout=5)` TimeoutError at DEBUG and returned silently. TimeoutError
is not OSError, so the push loop's `except OSError` at :441 never saw
it — the zombie writer sat in self._clients until the kernel's default
TCP keepalive detected the dead peer (Linux default: ~2 h 11 min).
Two hunks:
1. `_publish_to_report`: on drain TimeoutError, close the writer (best
effort, catch Exception so an already-broken close() doesn't mask
the raise) and raise BrokenPipeError, which IS OSError. Push loop
evicts on the same tick.
2. `_handle_client`: after SO_KEEPALIVE=1, set TCP_KEEPIDLE=60,
TCP_KEEPINTVL=15, TCP_KEEPCNT=4 — dead-peer detection in ~2 min
instead of ~2 h. `getattr(socket, ...)` guards keep it cross-
platform (macOS uses TCP_KEEPALIVE not TCP_KEEPIDLE, other kernels
may not expose all three — skip whichever is missing).
What I got wrong first pass and corrected on log-read: hypothesised
"missing MQTT session takeover on same client_id". Wrong. _handle_connect
parses the protocol client_id but discards it (assignment commented out
at :762), and self._clients is keyed on `f"{addr[0]}:{addr[1]}"` (socket
peer), so every reconnect gets a distinct key. No takeover race exists.
The log fixed this: the "not seen" symptom is BS-side (macOS UDP
receive after sleep + BS holding the pre-sleep socket state), but the
server-side amplifier was the zombie writer.
Non-proxy VP mode hardcoded the camera-passthrough TCPProxy to
listen_port=322 / target_port=322 regardless of the target printer's
model. That port is correct for RTSPS models (X1/X2/H2/P2S), but A1 /
A1 Mini / P1P / P1S use Bambu's proprietary chamber-image protocol on
port 6000. Result: A1/P1 targets got a 322 listener with no upstream,
OrcaSlicer Liveview failed with [2:-10061], BambuStudio's camera button
timed out.
Reporter confirmed a raw socat forwarder `<VP-IP>:6000 → <P1S-IP>:6000`
restored the stream — the target camera works, the VP just wasn't
publishing it.
Proxy mode was unaffected because SlicerProxyManager already opens 6000
(nominally file-transfer; Bambu reuses the port for chamber-image), so
the passthrough coincidentally works there.
Fix: read the target's model from
`printer_manager.get_client(target_id).model` at the same point we read
target_ip, then use `get_camera_port(target_model)` — the same source of
truth as routes/camera.py — to pick 322 or 6000. Model comes from the
physical printer, NOT self.model (the VP's spoofed identity has no
bearing on how the real device serves its camera).
Renamed the log tag from "RTSP" to f"Camera-{camera_port}" so support
bundles show which protocol the VP is fronting at a glance. Kept the
_rtsp_proxy attribute name to keep the diff tight; the block comment
spells out that it doubles as chamber-image passthrough on A1/P1.
A1 Mini firmware skips stg_cur=22 entirely, so the finish-photo fallback
fires at gcode_state=FINISH — which runs AFTER Bambu Studio has already
executed the user's End G-code. Users with SwapMod plate-swap injected
into End G-code always got a photo of the swapped (empty) plate.
Add a layer_num >= total_layer_num edge trigger in _parse_print_data so
the pre-capture fires the moment the last object layer completes, on
every printer variant. Guarded by the existing _finish_photo_captured
one-shot so stage-22 and FINISH-state hooks become no-ops for the same
print — no framing regression on AMS printers without custom end G-code.
usage_tracker's tray-switch split has never had a Spoolman peer.
An AMS same-material runout switch mid-print charged the whole slot
to the origin spool via the (via tag) path and double-credited the
backup via remain-delta — origin exceeded initial_weight.
Extract the segment-math into utils/tray_split.compute_tray_split_grams
and call it from both writers so the two inventory backends attribute
mid-print switches identically. spoolman_tracking gains
_report_spool_usage_split_by_tray_changes; the Path 2 remain-delta
fallback now skips trays the split path covered, killing the
double-count.
Carve MAINTENANCE_CREATE/UPDATE/DELETE out of the admin denylist so
HA automations can log "cleaned nozzle" / reset a counter via API key
without granting broader printer control. Follows the same shape as
can_manage_library and can_manage_inventory: new column, allowlist
entry, UI checkbox, wiki row, RBAC test coverage.
Distinct backfill: these perms were EXPLICITLY denied for every API
key before this change (no existing integration relies on them), so
existing rows migrate to FALSE — no silent scope widening on upgrade.
New keys default to TRUE, matching the safe-on-by-default pattern.
Bundled SpoolBuddy kiosk key gets False explicitly (kiosk doesn't need it).
get_setting_detail and delete_setting were hitting
/v1/iot-service/api/slicer/setting/{id} without the version query
parameter Bambu Cloud requires — every call returned HTTP 400
"field 'version' is not set". The sibling plural GET
(get_slicer_settings) has always sent it; the comment above
_SLICER_API_VERSION documents the contract for the endpoint subtree.
Missed when the placeholder landed in the 2026-05-12 compliance rework.
Downstream effect: slicer_filament_resolver.resolve_slicer_filament's
PFUS branch swallowed the 400, fell through to normalize_slicer_filament,
and caller inventory.py generic-material-fell-back tray_info_idx to
GFL99/GFG99. BambuStudio's AMS panel reads the printer's tray_info_idx
echo, so the user saw "Generic PLA" instead of the custom cloud preset.
Masked for 50 days by two rescue paths in the caller: prior-slot
tray_info_idx reuse, and stored spool_k_profile → live state.kprofiles
realign. Reporter's spool 54 → tray 2 assign had neither.
Adjacent surfaces also fixed by the same two-line change: the delete
cloud preset UI route, the whole update_setting flow (get_setting_detail
→ delete_setting → POST), preset_resolver's cloud branch, and three
UI-facing cloud.py routes that fetch setting detail.
get_setting_detail also includes the truncated response body in the
raised BambuCloudError so the next contract change is self-diagnostic
from support-bundle logs.
Symptom: user queued a batch of 10 prints, pressed Cancel on a pending
row, the print started anyway. Repeated consecutively. Support bundle
also showed 15x "sqlite3.OperationalError: database is locked" from the
sensor history recorder in the same 8-minute window.
Root cause is a check-then-act race in _start_print. check_queue takes
a snapshot of pending items, then _start_print does FTP delete + FTP
upload (5-30s) before the unconditional item.status = "printing";
db.commit() at line 2792. /cancel commits status='cancelled' in a
separate session during that window; the scheduler's stale in-memory
write overwrites it and start_print ships. The lock-contention finding
is the same shape from a different angle: _start_print did
await db.flush() at line 2555 (after item.archive_id set + library_file
delete) which opens the SQLite WAL writer lock and holds it through
the FTP upload, queueing every concurrent writer behind it including
the user's own cancel commit.
Three guards layered:
1) Atomic CAS at the pending->printing transition. UPDATE print_queue
SET status='printing', started_at=NOW() WHERE id=:id AND
status='pending'. rowcount==0 means user won; log abort, best-effort
delete_file_async the file we just FTP'd up so it doesn't leak into
the printer's BambuStudio file picker, send queue_item_failed WS
event with reason="cancelled_mid_dispatch", return without calling
printer_manager.start_print.
2) Early db.refresh(item) + bail right after the printer connectivity
check. Saves the wasted FTP upload when the row was already
cancelled before _start_print resumed. Defense in depth; guard 1
catches the same case at the CAS point.
3) flush -> commit before the FTP block. The library-file-to-archive
promotion's writes commit cleanly, WAL writer lock releases, sensor
history and concurrent cancels stop queueing behind the scheduler.
The flush-not-commit pattern was rolling back a pointer to an
already-committed archive row, so the new behaviour matches reality
(archive committed, pointer committed, FTP unblocked).
New scheduler stage that heats the bed (and the chamber, on supported
printers) and holds at temperature before each queued print starts —
the heat-soak engineering filaments need for adhesion and warp
control. Bambuddy waits between FTP upload and start_print, so the
soak runs while the printer is otherwise idle. M191 is silently
ignored by Bambu firmware, so doing this at the orchestration layer
is the only place it works.
Resolution order at dispatch:
1. PrintQueueItem.preheat_override ∈ {inherit, on, off}.
'off' skips entirely; 'inherit' falls back to the global
preheat_enabled toggle; 'on' forces the stage even when the
global is off.
2. chamber_target = item.preheat_chamber_target_override
?? max(filament_map[normalize(t.tray_type)] for loaded slots)
?? 0.
Mixed PA+PLA picks PA's 50 (max-across-slots — PA's chamber
requirement is binding, PLA doesn't suffer being warm). PLA-only
derives 0 and skips the chamber phase automatically.
3. Three hardware tiers for chamber heat:
- Active chamber heater (H2C/H2D/H2D Pro/H2S/X2D/X1E) → M141 +
chamber-sensor wait
- Chamber sensor only (X1C/P2S) → no M141, passive bed-radiation
wait with hard max-wait cap
- No chamber sensor (P1S/P1P/A1/A1 Mini) → bed + soak timer only
4. Airduct flap (H2C/H2D/H2D Pro/H2S/X2D/P2S) auto-switches to
match the chamber target — heating mode for engineering
filaments, cooling mode for PLA. Bambu firmware does NOT
auto-switch the flap with M141, so without this an ABS print
on a previously-cooling flap fights the open exhaust, and a
PLA print on a previously-hot flap recirculates ABS heat.
Idempotent: only fires set_airduct_mode when current ≠ desired.
Settings → Workflow → Queue & Dispatch → Preheat & Heat Soak card:
master enable toggle (default off — disabled installs see no change),
per-filament chamber-target editor (replaces a single global int that
shipped in the first cut and couldn't serve PA + PLA in the same
config), preheat_max_wait_seconds, preheat_soak_seconds. The Print
Options panel in PrintModal gets a Preheat sub-section with the
tri-state Inherit/On/Off control and an optional chamber-target
override input.
DB migration: PrintQueueItem gains preheat_override VARCHAR(10)
DEFAULT 'inherit' and preheat_chamber_target_override INTEGER NULL.
Idempotent via _safe_execute. Existing rows behave exactly as before
the migration.
Best-effort throughout: printer drops, refused M141 or set_airduct,
missing bed temp, lost MQTT state mid-wait all log and return cleanly.
Normal upload + start path runs after this returns regardless.
The HMS error modal had three compounding bugs that surfaced when a
user forced a wrong-plate HMS (0500_8051) and tried to dispatch the
per-fault actions.
(1) IGNORE_RESUME did not ignore. Bambuddy redirected the action on
state=PAUSE to a plain `resume` command, citing a #1830 verdict that
BambuStudio's "err-bearing shape" was firmware-silently-rejected.
BambuStudio source disagrees: DeviceErrorDialog.cpp:600 dispatches
IGNORE_RESUME via command_hms_ignore, whose wire shape is
{command:"ignore", err:"<decimal>", param:"reserve", job_id:...}.
That's a distinct command from `resume` — the firmware suppresses
the next re-check AND auto-resumes in one operation. Plain resume
means "re-check normally", which is exactly why the wrong-plate
detection re-fired 1-2 s after the user clicked Ignore. The #1830
"err-bearing shape rejected" test almost certainly sent the err as
a hex shortcode; BambuStudio passes std::to_string(int m_error_code)
i.e. the DECIMAL form, which is what the firmware matches against.
(2) Action buttons read as inert badges. The button className used
`hover:${buttonHoverColor}` — a template-literal interpolation
Tailwind's JIT scanner can't see as a literal string, so the
per-severity hover utility never reached the compiled CSS. Same
bg/text color as the severity badge above and no border made it
read as another label. No disabled state and no spinner during the
2.5 s ack wait left clicks sitting silently inert.
(3) Ack-detection 502'd on legitimate ack. The route compared
(gcode_state, hms_errors-len) before vs after publish; wrong-plate
re-pause round-tripped both fields to their pre-publish values
inside the 2.5 s window → false 502 even though the firmware fully
ack'd. PROBLEM_SOLVED_RESUME working but IGNORE_RESUME 502'ing on
the same fault was the same race resolving differently.
Fixes:
bambu_mqtt.py — new hms_ignore_command() publishes the BambuStudio
shape; existing hms_ignore(persistent) renamed to hms_idle_ignore
(unchanged shape, used by NO_REMINDER_NEXT_TIME per
DeviceErrorDialog.cpp:588). Dispatch routes IGNORE_RESUME,
IGNORE_NO_REMINDER_NEXT_TIME, and DONT_REMIND_NEXT_TIME to
hms_ignore_command (BambuStudio routes all three to the same
command_hms_ignore — the "don't remind" half is the firmware's
job). NO_REMINDER_NEXT_TIME stays on hms_idle_ignore type=0. Hex →
decimal err conversion at the helper layer with a defensive
fallback. job_id=None → empty string (matches BambuStudio's
std::string default).
HMSErrorModal.tsx — getSeverityInfo loses the dead buttonHoverColor
field. Action button uses static
`bg-white/10 hover:bg-white/20 active:bg-white/30 text-white
border border-white/20`, wires
`disabled={!hasPermission||mutation.isPending}`, and renders
`<Loader2/>` only on the button whose (action,print_error) matches
mutation.variables.
printers.py — ack-detection probes `client._last_message_time`
(bumped on every MQTT push regardless of payload) rather than
diffing state fields. The pushall that follows every command
guarantees a fresh push lands inside the 2.5 s window on any
healthy printer; only firmware-silent-drop leaves the timestamp
untouched, which is the 502 path #1830 wanted.
Two compounding bugs let an H2C-bound filament land in slot 1 of an A1
slice silently. (1) `_slicer_rejection_message` discarded the actual CLI
diagnostic - `filament preset Generic PLA @BBL H2C (slot 1) is not
compatible with printer Bambu Lab A1 0.4 nozzle.` - when the sidecar's
headline error_string was Bambu Studio's catch-all
`The input preset file is invalid and can not be parsed.` placeholder.
The real reason was in the stdout `[error] run NNNN:` line, trimmed off
before reaching the SliceJob's error_detail. (2) `pickFilamentForSlot`
used a soft `-100` mismatch penalty rather than a hard skip, leaving
the "never auto-fill an incompatible preset while a compatible one
exists" contract implicit. The unused-slot substitution in
`substitute_unused_plate_filaments` then propagated whatever slot 1
held across every unused slot - one bad pick poisoned the array.
(1) Mine `[error] <msg>` (with or without `run NNNN:`) from the full
pre-trim response; substitute the placeholder, keep meaningful
headlines. (2) Partition candidates into compatible/unknown vs
mismatch; prefer compatible whenever the bucket is non-empty, fall
back to mismatch only on graceful-degrade. Picker helpers moved out
of `SliceModal.tsx` into `utils/slicePresetPicker.ts` so the modal
file stays component-only (react-refresh lint).
Three test fixtures pass a string-shaped /tmp path into PrintArchive
rows. The file is never created — the field is just a DB column the
ORM accepts as a string — but Bandit's B108 rule fires on any literal
/tmp/ path it sees in source. Suppress with the same `# nosec B108`
marker convention test_archives_api.py and test_queue_start_user_attribution.py
already use for the same shape.
require_ownership_permission gates API keys on `all_perm` only — the
comment at auth.py:1659 says OWN and ALL "both map to the same scope
flag" for queue / archives / etc., so checking `all_perm` is the
correct gate. Library deliberately broke that: LIBRARY_UPDATE_OWN /
LIBRARY_DELETE_OWN mapped to can_manage_library, but the ALL variants
were in _APIKEY_DENIED_PERMISSIONS. Result — every API-key request to
DELETE /library/files/{id}, PUT /library/files/{id} (rename), or
POST /library/files/move hit "administrative operations" 403, even
for keys with can_manage_library=True. Only slice worked, because it
doesn't go through require_ownership_permission.
The "ALL stays admin-only because it crosses the user boundary"
intent was internally inconsistent. API keys have no per-row
ownership identity (user=None), so the route's
`file.created_by_id != user.id` ownership check would AttributeError
on a key acting under OWN anyway — the only working path is
can_modify_all=True, which `all_perm` denial blocked outright.
Fix folds LIBRARY_UPDATE_ALL and LIBRARY_DELETE_ALL into
_APIKEY_SCOPE_BY_PERMISSION under can_manage_library, matching the
can_queue precedent (QUEUE_UPDATE_OWN and QUEUE_UPDATE_ALL both
map to can_queue for the same per-key-identity reason). Both removed
from _APIKEY_DENIED_PERMISSIONS. LIBRARY_PURGE stays denied — it
bypasses the soft-delete window and is genuinely destructive.
The toast was calibrated for power users — lowest bars were 100 prints,
50 archives, 100 cost. Most installs never crossed any of them, especially
with the install base ~doubling since March. Matomo confirms: only 4
prints-100 and 3 archives-50 deeplink visits to /sponsors.html in a 7-day
window despite tens of thousands of weekly pulls.
Adds lower thresholds without changing priority order or cooldown:
PRINT_MILESTONES = (10, 25, ...)
ARCHIVE_MILESTONES = (5, 10, ...)
COST_MILESTONES = (25, 50, ...)
Existing toast copy uses {count}/{total} interpolation in all 11 locales,
so no i18n changes. Tests rebalanced so "below the floor" still tests
with the new floor; new test_fires_at_lowest_threshold pins prints-10.
On dual-nozzle printers (H2C/H2D), the External card stacked a
separate "Ext-L" / "Ext-R" caption below each tray to mark which
extruder it fed. That caption appeared on the External card only,
making the bottom row of the printer card's AMS panel visibly
taller than the row above it.
Fix: the L/R distinction now lives inside the slot's colour circle
in place of the numeric index, and the bottom caption is removed.
FilamentSlotCircle's slotNumber prop is widened to `number | string`
to carry the letter. Single-nozzle externals (one tray, no L/R
distinction) keep the numeric "1".
The Ext-L / Ext-R strings still drive the slot's "location" label
in the filament hover card, so detail context is preserved.
pip-audit flagged two advisories at the resolved versions in the venv.
Neither is reachable in shipped Bambuddy, but the pins are taken so
the audit stays clean and a future reachable advisory in either
package isn't masked by existing noise.
pydantic-settings 2.14.2 patches GHSA-4xgf-cpjx-pc3j —
NestedSecretsSettingsSource with secrets_nested_subdir=True followed
symlinks pointing outside the configured secrets_dir, reading
out-of-tree files into settings values and bypassing the documented
secrets_dir_max_size cap. Affected: >=2.12.0, <2.14.2. Bambuddy uses
pydantic-settings only for env-var-backed config; the secrets-dir
loader is not used (grep clean on NestedSecretsSettingsSource /
secrets_nested_subdir / secrets_dir under backend/).
msgpack 1.2.1 patches GHSA-6v7p-g79w-8964 — reusing an Unpacker
instance after it caught an error can crash with SEGV, which is a
DoS vector on untrusted input. msgpack is not a runtime dep of
Bambuddy; it enters the tree only as a transitive of CacheControl,
itself pulled by pip-audit (the very tool that surfaced the
advisory). Pin placed in requirements-dev.txt next to pip-audit so
it travels with the security-scan tooling rather than implying a
runtime use.
Adds a global local_login_enabled setting plus a per-provider
is_autologin flag on OIDCProvider so operators who run their own SSO
enabled, or if the calling admin has no UserOIDCLink — either would
lock everyone out. App-layer invariant: at most one provider can carry
is_autologin; setting it on one clears it on every other.
/auth/advanced-auth/status surfaces both new fields so the LoginPage
decides UI in one query. The env-var bypass flips the reported
local_login_enabled back to true so the SPA matches what the route
will accept.
Reprints triggered a bogus "Print Stopped" push notification while the print
kept running, surfaced by the reconciler synthesising a missed PRINT COMPLETE
on MQTT reconnect.
bambu_mqtt:3647 mints a fresh subtask_id per dispatch. On reprint, the
on_print_start expected-archive promotion only wrote subtask_id when the
stored value was empty (`not archive.subtask_id`) — so the archive kept the
FIRST run's id. On the next MQTT reconnect, reconcile_stale_active_prints
(#1542) compared the stale stored id against the printer's live id, found
a mismatch, and synthesised a status="aborted" PRINT COMPLETE — which fires
the "Print Stopped" notification.
Captured cleanly in the reporter's support bundle:
[RECONCILE] Printer 1: synthesising missed PRINT COMPLETE for archive 31
— subtask_id changed ('1844213296' → '2103771517')
immediately followed by gcode_state: RUNNING on the same wire.
Fix: update archive.subtask_id whenever the new effective id differs from
the stored one, not only when the stored one is empty. Inequality check
preserves the noop-on-stable-push behaviour the original guard provided.
Two places in main.py (expected-print and duplicate-printing-archive
branches). 3 new unit tests cover the reprint, first-run, and stable-push
paths. Reconciler itself unchanged — it was doing the right thing given
the data it had.