Queueing several plates of one 3MF built a single filament-override list from
every selected plate and posted that same list with each plate's item. A
force_color_match entry blocks dispatch until the printer has that exact colour
loaded, so a single-colour plate waited on the whole batch's palette. The same
shared list also widened required_filament_types, making a PLA plate refuse
every printer that lacked a sibling plate's PETG.
Narrow the overrides to the slots the plate actually consumes, on create and on
update -- in the backend, where the 3MF is, so it holds for every writer of the
queue. Dispatch already re-parsed requirements per plate and keyed overrides by
slot, so the dropped entries were inert there. When the plate's slots cannot be
read the overrides are kept whole: an item waiting on a colour it does not need
is visible, one that silently lost a forced colour prints in the wrong filament.
Items queued before this would stay stuck with a waiting reason that explains
nothing, so a startup migration re-scopes the pending ones. Printing and
finished items keep their overrides -- that is a record of what they dispatched
with, not an instruction.
Nightly backups to a mounted NAS share ran from May and then stopped, failing
with [Errno 30] Read-only file system. The reporter checked folder permissions
-- correctly: the mount is gid=backup,dir_mode=0775, the service user is in that
group, and his own shell writes to the share fine.
Errno 30 is EROFS. A permission problem is errno 13. EROFS means the filesystem
refused the write, and it refused because we told it to: our systemd unit ships
ProtectSystem=strict, which mounts everything read-only inside the service's
mount namespace and carves back out only ReadWritePaths=<install> <data> <logs>.
A NAS share is not one of those three. Reads are unaffected -- which is why the
UI happily listed his existing backups from the share while being unable to
write a new one -- and his shell is outside the namespace entirely, so every
check he could think to run said the directory was fine.
Both installers write the unit file wholesale, so a ReadWritePaths line added by
hand disappeared on the next install, taking the backups with it. They now back
the old unit up (.bak-<timestamp>) and carry the operator's extra writable paths
forward, reporting which ones they kept. The unit template documents the
carve-out.
The output directory is probed with a real write when it is saved and when the
backup card loads, so an unwritable path is caught there rather than at 03:00
for a week. On failure the card names the cause and hands over the fix with the
operator's path already in it (systemctl edit bambuddy -> ReadWritePaths=...),
and a failed run reports the same diagnosis rather than the raw OSError. EROFS
outside systemd, permission-denied, out-of-space, not-a-directory and missing are
told apart, in all 11 locales.
Docker: a backup path that is not bind-mounted is writable -- the write lands in
the container's ephemeral layer and is lost on the next compose up. The probe
compares the directory's device against the container root and warns, with the
compose snippet that mounts it properly.
A Shelly reports one energy figure — aenergy.total, a lifetime counter in Wh
that never resets. Bambuddy had a single REST energy field and filed whatever
it found under "today", so the value never reset at midnight, and Yesterday
and Total stayed at zero: get_energy() simply never set those keys.
With `total` unpopulated, the hourly snapshot recorder skipped the plug, so
the Statistics page's energy figure was zero as well, not just the Settings
card.
Split the REST energy config in two: rest_energy_path still means "used
today", rest_energy_total_path means "lifetime counter". A Shelly has only
the latter; a Tasmota behind a REST bridge has both; sharing a URL costs one
fetch, not two.
Then derive Today and Yesterday from that counter using the snapshots we were
already taking: today = counter now - counter at the last local midnight;
yesterday = the gap between the two previous midnights. Local midnight, not
UTC — a UTC boundary rolls Today over at 02:00 in Berlin. The snapshot loop
now ticks on the local hour so a reading lands on the boundary instead of up
to an hour early. A counter that goes backwards (factory reset) reports
nothing rather than a negative.
Collateral, found while verifying on both engines: the smart-plug DateTime
columns are naive UTC but the code wrote aware datetimes into them. SQLite
drops the offset; asyncpg raises DataError. So on Postgres every snapshot
capture raised inside the loop's except, and every status poll raised on
last_checked — the whole subsystem was dead on the database we recommend for
multi-printer installs. All plug timestamps are naive UTC now.
Existing REST users with a cumulative path in the today field must move it to
the new lifetime field; the form and wiki now name which counter each wants.
Cam Wall had no URL — the only way in was the toggle on the Printers page,
so it could not be bookmarked, linked, or shown on a wall-mounted screen.
Add a standalone /camwall route. Signed in, it is the wall as it was. For a
TV or Pi with no login, it authenticates with a long-lived token in the URL.
A kiosk needs the printer list and per-printer status, both of which sit
behind PRINTERS_READ. Rather than widen camera_stream to cover GET /printers
— whose response carries serial_number and ip_address, which have no business
on a screen in a shared room — add a read-only feed at
GET /api/v1/camwall/printers that serves only what a tile draws, and gate it
on a new camwall token scope. The print filename is not served at all: a token
wall renders the compact overlay, so the part on the bed is never named.
The scope is separate rather than a widening: camera_stream tokens are already
in the wild, minted to hand out video, and must not gain the ability to
enumerate a fleet by name. camera_stream is refused by the feed; camwall
passes the stream gate so its own tiles fill.
Kiosk walls drop the settings popover and click-through entirely (not merely
hidden — a passive screen must carry no focusable control it cannot act on),
cap the overlay at compact, and poll rather than open a WebSocket. maxLive,
interval and status can be set from the URL, clamped to the popover's ranges.
A server-mode VP with a target printer bound showed the print as a bare
filename in Bambu Studio / OrcaSlicer -- no stage, percentage, layer count or
time remaining. The data was already in the bridge cache; we were overwriting
it with zeros, because passing it through made the slicer read the VP as busy
and hide the Send button (#1558).
Both slicers gate the progress panel and the Send button on one predicate,
MachineObject::is_in_printing() -- gcode_state in RUNNING/PAUSE/SLICING/PREPARE
-- so there is no field-level way to have both. FINISH is the one state in the
gap: StatusPanel::update_subtask() renders the panel for it, and
SelectMachineDialog::update_show_status() does not disable Send. The VP already
parks at FINISH after each upload (#1280 / #1658), so it only needed the real
numbers underneath it.
While the target prints and no upload is in flight, the report now holds
gcode_state=FINISH and passes mc_print_stage, mc_percent, mc_remaining_time,
stg, stg_cur, layer_num and total_layer_num through from the cache. Mirroring
is suppressed during PREPARE and for 5s after the last upload transition, so
the slicer still receives the FINISH carrying its own subtask_name and releases
its send modal. print_error is never mirrored -- it would raise a modal error
dialog for a fault the VP did not throw.
The reporter found what his P1S was doing, and it is in Bambu's P1 manual:
"P1S connected AMS drying functions may only be controlled from the P1S screen."
The firmware acks ams_filament_drying with result: success and then discards it,
which is why three commands on an idle printer left the AMS 2 Pro at dry_status 0.
No command can start a cycle on a P1, on any firmware, so don't offer one.
supports_drying() now excludes the P1 series outright, replacing the 01.08+ gate
carried since #292 — that version is when P1 firmware gained AMS 2 Pro support,
not remote drying, and it was never checked against a live P1. Both drying routes
refuse with a specific 400 instead of publishing a message the printer will drop;
queue and ambient auto-drying skip P1s via the same helper.
A new drying_screen_only flag keeps the control on the card, disabled, saying why
— a P1 owner needs to learn where to dry, not watch the button disappear. A cycle
started at the printer still shows with its countdown; only Stop goes away, since
a P1 ignores stop exactly as it ignores start.
Also corrects the wiki firmware matrix, which listed P1P/P1S as supported and
(separately) P2S/H2S/H2C as unsupported. 8 tests.
extract_printable_objects_from_3mf() has accepted a plate_number since it was
written and no caller ever passed one, so it took root.find(".//plate") — the
first plate in the file. Passing one would not have helped either: the lookup
was .//plate[@plate_idx='N'], a predicate on an attribute neither Bambu Studio
nor OrcaSlicer writes. The index lives in a <metadata key="index"> child, as
threemf_tools and filament_requirements already read it, so the selector never
matched and fell back to plate 1 regardless.
On an all-plates .gcode.3mf that meant Skip Objects offered the wrong plate's
objects, with that plate's marker positions drawn over the correct plate's
thumbnail (/cover resolves the plate properly via resolve_plate_id, the object
list did not). The reporter printed a one-object plate and was shown the four
copies from another plate of the same file.
Select the plate on its index metadata, and pass resolve_plate_id(state) at all
three call sites so the list and the thumbnail share one resolver. Also stop
peek_plate_index_in_3mf() reporting plate 1 for a multi-plate file: it backs the
running one, so an all-plates upload printing plate 2+ lost its archive entirely.
upload_file_async carried a flat 600s wall-clock deadline and ran the
transfer via asyncio.wait_for(run_in_executor(...)). wait_for cancels the
future, not the executor thread. A 96 MB 3MF to an A1 over WiFi sustains
~75 KB/s and needs ~20 minutes, so the await gave up at ~70 MB, returned
False, and with_ftp_retry started a second STOR of the same file onto the
same printer while the first was still streaming. The reporter filmed two
transfers of one job climbing in parallel at 2% and 72%; the print never
landed and the printer read as having a flaky network.
The deadline is now derived from the file size against a 25 KB/s floor, so a
slow-but-healthy transfer can finish — a link that has actually died is
caught within socket_timeout by the blocking sendall, which is what should be
detecting failure. A deadline expiry now stops the transfer for real: the
worker is signalled, raises UploadCancelled from its progress callback, and
upload_file's existing cancel path breaks the send loop and deletes the
partial file. with_ftp_retry never retries that, and a per-printer lock makes
overlapping uploads impossible however they were triggered.
Failed to get cloud preset ... 400 {"message":"missing"} is the expected
answer, not a fault: many official presets are only addressable with a
printer-variant suffix (GFSL05 exists solely as GFSL05_07 @BBL A1), and
personal P-prefixed presets belong to the account that sliced the file.
Phase 3 already resolves both from local presets, so the lookup miss is
routine -- and one WARNING per AMS tray per tooltip refresh teaches
operators to ignore the log.
BambuCloudError now carries the upstream status_code. The preset lookup
logs HTTP 400 at DEBUG; expired tokens, 5xx and transport failures stay
at WARNING.
Not fixed here: resolving the variant suffix. It selects a printer profile
and the response carries that profile's pressure_advance, so guessing a
suffix would report another printer's K value.
ssl.create_default_context() leaves minimum_version at MINIMUM_SUPPORTED,
so the floor came from the OpenSSL build rather than from Bambuddy. On
identical OpenSSL 3.5.6, python:3.13-slim-trixie reports TLSv1_2 while a
bare-metal venv reports MINIMUM_SUPPORTED -- Docker installs were floored
at 1.2, bare-metal and appliance installs were not.
Set minimum_version explicitly in ImplicitFTP_TLS and the MQTT client. On
the P2S/X2D profiles that also cap maximum_version this becomes an exact
TLS 1.2 pin. Probed against an X1C and an H2D on :990 and :8883: both
complete only on TLS 1.2 and reject 1.0, 1.1 and 1.3; live FTPS login
through the new path succeeds on both.
Also correct a stale comment in ftp_profiles.py -- X1C and H2D refuse
TLS 1.3, so cap_tls_v1_2 is a no-op there, contrary to what it claimed.
A spool with no readable RFID was reported by the standard AMS with an empty
tray_type and state=9 — structurally identical to a truly-empty slot at the
tray level — so the AMS card rendered it "Empty" while Bambu Studio correctly
showed "?". The authoritative "a spool is physically here" signal is firmware's
AMS-level tray_exist_bits bitmask (what Studio uses), but Bambuddy inferred
emptiness from the per-tray state/tray_type. Confirmed from the reporter's
bundle: tray_exist_bits=f (all four slots present) with tray_is_bbl_bits=5
(only slots 0,2 Bambu) — the present-but-non-Bambu slots were the ones shown
Empty. Supersedes closed#1838.
apply_tray_exist_bits() already parses the bitmask to clear stale fields on
absent slots; it now also annotates each slot with an authoritative `exists`
bool, gated behind a new annotate_exists flag so only the printer-card path
sets it. The VP bridge leaves it off, so the `exists` key never reaches the
slicer wire format. `exists` flows through the AMSTray schema/serialization to
the frontend, where getEmptySlotKind() uses it: exists===true + no tray_type
-> "?" (present, unconfigured), exists===false -> "Empty", exists absent ->
the previous state=9/10 heuristic (AMS-HT and missing-bitmask paths unchanged).
H2D/X1C already reported present-unknown slots with a non-9 state and took the
"?" path; with the fix they reach it via `exists` and are unaffected.
P1-series printers have a MicroSD slot but no reachable control to enable
"Store sent files on external storage": current P1 firmware (through
01.10.00.00) never publishes support_save_remote_print_file_to_storage, so
the Bambu Studio toggle never renders, and the P1S has no screen — leaving
store_to_sdcard stuck False with no way for the user to change it. The
external_storage check reported a permanently-unresolvable fail.
Add NO_REMOTE_STORAGE_TOGGLE_MODELS (P1S, P1P) + has_remote_storage_toggle(),
kept distinct from the no-slot NO_EXTERNAL_STORAGE_MODELS. When a model has a
slot but no reachable toggle and the option is off, the check now emits skip
with params reason=unsupported_model rather than fail, and overall no longer
escalates. A P1S reporting the option on still passes. Model-scoped and
default-open, so X1/P2S/H2 (where the fail is actionable) are unaffected; if
a future firmware surfaces the capability, drop the model and it reactivates.
The frontend DiagnosticChecklist renders a reason-specific message variant
(external_storage.skip_unsupported_model) so P1 users see an accurate
explanation instead of the generic "needs a live MQTT connection" skip text.
The fix propagates to the support-bundle diagnostic snapshot automatically.
Chamber-image printers came up black on load and only recovered ~20 min
later. Two causes: (1) late fan-out subscribers got an empty queue and
waited for the next frame, so the browser never fired onLoad and the
stall-detector reconnect-looped, churning short-lived viewers; (2) the
churn reopened the port-6000 socket before the old one closed, so the
printer fed an orphaned socket until its TCP keepalive reaped it.
Prime late subscribers with the last pumped frame; make a replacement
broadcaster's pump wait for the predecessor's socket to close before
dialing (bounded 10s); require two consecutive stalled reads before the
frontend reconnects.
The Configure AMS Slot picker was hardwired to 0.4mm (nozzleDiameter
prop never passed from PrintersPage / SpoolBuddyAmsPage), so a 0.6
machine could only set 0.4 profiles on its trays. Resolve the real
installed nozzle per-AMS (ams_extruder_map on dual-nozzle) and pass it
in. Separately, nothing validated the sliced nozzle against the
installed one, so a mismatch reached the printer as a cryptic HMS
_8012 "Failed to get AMS mapping table". Add a fail-safe pre-dispatch
guard in _start_print that fails the item with an actionable message
before upload; no slice diameter or no reported nozzles = no-op.
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.
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.
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.
P1S has an enclosure door but no hall sensor for it; P1P has no
enclosure at all. Both models were rendering a permanent green
"Door Closed" chip driven by bit 23 of the stat field, which stays
0 forever on that firmware. Whitelist now covers only models that
actually ship with a door sensor: X1 family, X2D, P2S, and H2 family.
Corrected the matching stale comments in the PrinterStatus TS
interface (client.ts) and PrinterState dataclass (bambu_mqtt.py).
Backend parse left as-is — cheap and future-proof if Bambu ever
wires the P-series enclosure into a sensor.
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.
PR A/B turned the slice modal's preset bundle into a one-click dispatch
with a pinned target printer. PR C closes the original issue: operators
type in a number of copies, Bambuddy slices once and distributes prints
across a fleet per the pipeline's chosen fanout strategy. A new dashboard
surfaces every run with filters, expandable per-copy status, cancel,
and retry-failed-copies. WS pushes keep everything live.
Backend
- copies field on POST /run, capped by new pipeline_max_copies setting
(default 50, hard cap 1000). PipelineRun.parent_run_id chains retries.
- SlicerPipelineUpdate accepts target_kind (specific_printer /
printer_class), target_model_class, fanout_strategy.
- Eligibility matcher branches: class-targeting enumerates matching
Printer rows, runs per-printer checks via a status_lookup closure,
returns printer_reports[]. New issue kinds: no_class_matches,
class_not_set.
- _pick_assignments distributes copies per strategy:
- max_parallel: target_model set, printer_id None — scheduler picks
- round_robin: copy i → eligible[i % N], fixed printer_id
- fill_one_first: all copies pinned to eligible[0]
All three reuse the slice-once path through slice_dispatch.enqueue.
- New routes:
- GET /pipeline-runs (paginated, filterable by pipeline + status)
- POST /pipeline-runs/{id}/retry-failed (creates child run with
copies = failed+cancelled count, parent_run_id set)
- Cancel cascades to all N queue entries (only pending/queued)
- _roll_up_run_status computes run-level status from per-job statuses;
introduces partial_failure for "some completed, some failed".
- ws_manager.broadcast_to_user emits pipeline_run_updated on every
state transition with the full materialised response.
Frontend
- Pipeline editor: target_kind radio + class picker (filtered to
installed models) + fanout-strategy radio. Read-only row shows
"X1C · Round robin" for class pipelines.
- RunWithPipelineModal: copies number input bounded by
settings.pipeline_max_copies. Accepts class-targeted pipelines.
- Settings → Workflow → Queue & Dispatch: new "Slicer Pipeline limits"
card with the max-copies input.
- New /pipelines/runs dashboard page (sidebar entry, gated on
pipelines:read). Two-filter dropdown, 25-per-page pagination, per-row
expandable to job list, Cancel + Retry-failed buttons.
- useWebSocket case for pipeline_run_updated invalidates both
pipeline-runs-all and pipeline-runs/{id} query keys.
Two real gaps from the PR B drop:
1. Run-with-pipeline only existed in the file manager. Operators who keep
working files in archives had to copy them to the library to use a
pipeline.
2. Triggering a slice via a pipeline produced a silent multi-second-to-
minute wait. The manual SliceModal flow shows the sticky
"Slicing X - Generating G-code 75%" persistent toast; the pipeline
path went through asyncio.create_task directly and never registered
with SliceJobTracker.
Archive entry point
- POST /slicer-pipelines/{id}/check-eligibility and /run accept
source_archive_id as an alternative to source_library_file_id (XOR,
enforced by Pydantic validator).
- PipelineRun.source_archive_id is a new nullable FK column with the
ALTER TABLE migration in run_migrations (idempotent via _safe_execute,
works on SQLite + Postgres).
- _resolve_source branches: archive path reads source_3mf_path with
fallback to file_path, mirroring routes/archives.py.
- ArchiveCard's context menu picks up a "Run with pipeline" item next to
Slice (only on source archives), gated on useSlicerApi + pipelines:run.
Slice (only on source archives), gated on useSlicerApi + pipelines:run.
- Path-safety: SEC-PATH-OK markers added at both LibraryFile.file_path
and archive.source_3mf_path join sites, citing the upload-time
validators.
Progress toast
- Pipeline orchestration is now the `run` callable of a
slice_dispatch.enqueue call — the same dispatcher SliceModal uses —
instead of a bare asyncio.create_task. The SliceJob lifecycle drives
the existing progress toast end to end with no separate notification
surface for pipeline runs.
- PipelineRun.slice_job_id is set before the 202 returns.
- RunWithPipelineModal calls useSliceJobTracker().trackJob() from
runMutation.onSuccess.
- RunWithPipelineModal source prop is now {kind, id, filename}
mirroring SliceModal.SliceSource; api.checkPipelineEligibility +
api.runPipeline take a discriminated-union source argument.
FTP push to the printer into the server-side scheduler tick. That
removed the browser-side upload the old XHR-progress modal listened
to — users only saw the queue item flip to "active" with no visibility
into the FTP push + the H2D/H2D Pro 80-210 s project_file digestion
window before the printer actually started.
Port the legacy bg-dispatch toast rendering from
0b43ac0d:frontend/src/contexts/ToastContext.tsx lines 510-650 back in
place verbatim — same DOM tree, same Tailwind classes, same
formatFileSize bytes line, same uppercase status chip, same collapse
chevron, same awaitingPrinter derivation, same auto-dismiss. The only
adapt is the event ingestion: a useEffect maps the four scheduler-side
WS events to the legacy DispatchToastJob shape.
The toast materializes when the FTP push to the printer ACTUALLY
STARTS (queue_item_uploading) — NOT on POST /queue. A draft that
fired at queue-add made the toast jump to "Dispatched" before any
upload had happened.
Four backend WS events drive it: uploading (carries printer_name +
total_bytes), upload_progress (throttled at 200 ms / 256 KB to match
legacy background_dispatch.py:614-615 1:1, first call always emits,
completion always emits; an _UploadProgressBridge bridges from the
FTP executor thread to the asyncio loop), acked (printer transitioned
out of pre_state), failed (with a reason key the toast looks up as
dispatchToast.failed.{reason}). No queue_item_dispatched event: the
legacy path kept status=processing from upload start until printer
ack, "Awaiting printer..." derives from upload_progress_pct >= 99.9
(legacy uploadDoneAwaitingPrinter trick).
Per-user routing: WS connect resolves the principal username to
User.id once and stashes it on websocket.state, so
ws_manager.broadcast_to_user filters O(connections). Auth-disabled
installs route user_id=None to all connections — matches the legacy
single-user behaviour. The watchdog receives created_by_id through a
new kwarg so the static method can still emit acked without
re-fetching the queue item.
Three distinct bugs combined into one user-facing failure: clicking
Stop / Problem-solved-and-resume / Ignore-and-resume returned 200 OK
but the printer didn't act, modal stayed up, print stayed paused.
Verified by injecting candidate command shapes on device/<sn>/request
against a live H2D paused on a wrong-plate HMS (print_error=0x05008051).
(1) hms_resume / hms_stop dispatched the "err"-bearing shape that
BambuStudio doesn't actually send; Bambu firmware silently rejects it.
Both now send the plain shape ({"print":{"command":"<x>","param":"",
"sequence_id":"0"}}). PAUSE -> FAILED in 1.7s for stop, PAUSE -> RUNNING
in <2s for resume.
(2) IGNORE_RESUME mapped to idle_ignore, which is BambuStudio's
"dismiss a warning" command and only works for non-pause warnings.
hms_ignore now branches on state.state == "PAUSE": paused -> plain
resume; not-paused -> idle_ignore with the full-length err.
(3) 64-bit hms[]-array faults were truncated to a non-matching err.
short_code in _parse_status discarded 32 of the 64 identifier bits, so
the firmware didn't match it to the active fault. HMSError.full_code
now carries the canonical hex identifier (16 chars for hms[] faults,
8 chars for print_error faults). Catalog lookup tries 16-char first,
falls back to 8-char. HmsActionBody.print_error pattern relaxed to
^[0-9A-Fa-f]{8}([0-9A-Fa-f]{8})?$.
(4) execute_hms_action returned publish-success as success, masking
every silent-rejection bug above as 200 OK. Route now snapshots
(state.state, len(state.hms_errors)) before dispatch, awaits
HMS_ACTION_ACK_WAIT_SECONDS (default 2.5s, module-level so tests
override), and returns 502 with "Printer did not acknowledge HMS
action within 2.5s" if state didn't move.
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.
streams when one viewer closes
1) Offline tiles now show OFF (not LIVE)
CameraWall.modeByPrinter assigned 'live' to any visible printer
without considering status.connected, so a disconnected X1C wasted
a live-budget slot AND rendered the red LIVE chip on top of the
WifiOff placeholder. Disconnected printers now map to 'paused' and
don't decrement liveBudget — the existing WifiOff + Off chip
rendering takes over.
2) /camera/stop no longer kills other viewers' streams
The cam-wall tile, EmbeddedCameraViewer, and the /camera/:id popup
all subscribe to the same fan-out broadcaster for a printer.
/camera/stop used to unconditionally shutdown_broadcaster() + kill
every ffmpeg process for the printer, so closing the embedded viewer
while the cam-wall tile of the same printer was live force-killed
the source the tile was pulling from — the tile's <img> errored.
New get_subscriber_count(key) accessor in camera_fanout.py exposes
the broadcaster's subscriber list length. /camera/stop now reads
that first; when >= 1 subscriber is still attached, return
{stopped: 0, skipped: true} and leave the broadcaster + ffmpeg
processes alone. The leaving viewer's HTTP teardown still runs the
natural iter_subscriber.finally -> unsubscribe path, so its slot is
released; the broadcaster keeps serving the other viewers. Single-
viewer close still hits the immediate force-teardown (count is 0).
Bambu's per-tick AMS push carries only the dry_time countdown — the
filament name and target temperature the user chose are never echoed on
the wire. The AMS card had no source of truth for them and rendered the
bare "Drying · 11h 35m left". The badge now shows
"Drying · PETG @ 65°C · 11h 35m left", matching the cycle the user
actually started.
BambuMQTTClient caches {ams_id: {filament, temp}} on send_drying_command
(mode=1), clears on mode=0 and on the dry_time falling edge to 0 — the
same per-AMS edge detector that drives the smart-plug-after-drying
callback. PrinterManager.get_drying_targets exposes it, the four
printer_state_to_dict call sites thread it through, AMS schema gains
dry_target_temp + dry_filament, and routes/printers.py builds the same
fields into the manually-constructed AMSUnit response.
When no cached target exists (drying started in a previous backend
lifetime, or initiated outside Bambuddy), the badge falls back to the
first loaded tray's tray_type + RFID-recommended drying_temp — the
heuristic the popover already uses to seed defaults.
i18n: printers.drying.targetSummary = "{{filament}} @ {{temp}}°C" in
all 11 locales. Parity check 5356 leaves per locale.
Note: a user reported the H2D's own physical display still labels the
cycle by the loaded tray's filament (e.g. "PLA" instead of the
Bambuddy-requested "PETG"). The wire payload is correct end-to-end —
journalctl shows filament: "PETG" sent and result: success ACKed — and
the badge in Bambuddy's own UI now reflects what we actually sent,
independent of the firmware's display choice.
Continue Auto-Drying while a print is running on capable hardware.
New Settings > Print Queue > "Continue drying while printing" toggle
(default OFF). Extends _check_auto_drying in print_scheduler.py to
evaluate running printers when supports_drying_while_printing(model,
firmware) returns true. Strict allowlist verified per Bambu wiki
release notes for "Print While Drying" / "printing while filament is
drying": H2D 01.03.00.00+, H2C/H2S/P2S/H2D Pro 01.02.00.00+, X2D/A2L
01.01.00.00+, X1C 01.11.02.00+. P1*, A1, A1 Mini, X1 (non-C), X1E
intentionally excluded. Mid-print drying temperature is capped at
max(40, preset_temp - 5) to protect spools from heat damage inside the
hot enclosure during a print, matching Bambu's own "lower drying
temperature during printing" guidance.
Rotate-spool toggle in the drying popover is now disabled when any tray
in the targeted AMS has filament threaded into the feed tube
(tray.state === 11). The whole AMS rotates as one mechanism, so a
single loaded slot locks the entire unit. Previously the toggle was
always clickable and the firmware rejected with dry_sf_reason=[3]
(ConsumableAtAmsOutlet) after the click. The first cut keyed on the
printer-level tray_now but missed the H2D's typical post-print state
where tray_now resets to 255 while filament stays in the tube — the
per-tray state field reports it correctly. Submission also clamps
rotateTray off so a stale-true state from a previous AMS can't leak
through.
Backend: supports_drying_while_printing in printer_manager.py covers
display names and internal SSDP/MQTT codes (O1D, O1E/O2D, O1C/O1C2,
O1S, N6, BL-P001, N7, N9). New print_drying_enabled boolean in
settings schema. Frontend: toggle on SettingsPage, gate + clamp on
PrintersPage drying popover using existing amsData cache. i18n: 3 new
keys x 11 locales, no English fallback. Tests: 7 cases on the gate
matrix (TestSupportsDryingWhilePrinting), 4 cases on the scheduler
mid-print path (TestMidPrintDrying), 9 cases on the rotate gate state
transitions. Full backend pytest -n 30 green (4251/4251), ruff clean,
frontend npm run build clean, i18n parity 5355 leaves per locale.
Brings the Spoolman writer up to parity with the internal-inventory
side, which has had this fallback since #1119. When a Bambu print
starts without a retrievable .gcode.3mf on the printer (typically an
unsaved BambuStudio project, subtask_name='Untitled'), Spoolman no
longer silently skips the print's filament consumption.
- ActivePrintSpoolman.filament_usage now nullable; new tray_remain_start
column captures per-slot {remain, tray_uuid} at print start.
- store_print_data: always snapshots remain, even when 3MF is present
(mirrors usage_tracker.on_print_start), so partial-3MF prints can also
fall back per-slot.
- report_usage: 3MF path stays primary; new _report_remain_delta_for_slots
handles slots the 3MF didn't cover via delta * Filament.weight / 100,
resolving the spool via the existing slot-assignment table.
- _report_partial_usage: same fallback for aborted no-3MF prints.
#1119 invariant preserved: per-slot, per-print, gated on a valid
start/current remain AND a resolvable Spoolman spool. Uses curated
Filament.weight (not MQTT's unreliable tray_weight) — same trick the
internal-inventory side uses.
Mid-print spool swap detected via tray_uuid mismatch → slot skipped.
Double-charge prevented via handled_global_tray_ids dedup.
SpoolBuddy "Assign to AMS" with a Bambu Cloud user preset (PFUS) left
Bambu Studio showing "Generic <Material>" instead of the user's
custom preset. Root cause: the defensive filter that catches
PFUS/PFCN leaks into tray_info_idx also cleared setting_id —
but PFUS/PFCN are VALID setting_id values, just not valid
tray_info_idx values. When the cloud detail lookup didn't return
a filament_id (cloud unauth on the on_ams_change replay path,
transient failure, or older custom presets), both fields got
cleared and the caller's generic-material fallback overwrote
setting_id with GFSG99 — slicer resolved to Generic PETG.
Fix: the filter still clears tray_info_idx for PFUS/PFCN/material-
name leaks, but preserves setting_id when it's a valid slicer
reference (PFUS / PFCN / GFS). Material-name leaks still clear
both. Post-fix MQTT carries tray_info_idx=GFG99 (firmware-acceptable
for HMS/drying/colour) AND setting_id=PFUS<hash> (slicer uses this
to load the actual user preset).
What stays the same: Bambuddy's own AMS card still displays
the generic material on cloud-unauth paths — same fundamental
limitation as today. Fixing that needs a deeper layered fallback
(LocalPreset name match, printer kprofile query, cloud-detail
cache) and is out of scope for this drop. Slicer-side fix is
the reporter's explicit ask.
H2S firmware reports tray_now=0 (the AMS's idle slot) throughout
external-spool prints instead of 254 like X1C/P1S/A1 do, so the
single-nozzle branch's 0-3 passthrough landed state.tray_now on slot
0 — UI highlighted AMS SLOT 1 instead of the external spool.
Usage credit was unaffected (#1276 covers that via ams_mapping).
The single-nozzle branch now checks _captured_ams_mapping (slicer-
captured per-filament mapping that the request-topic intercept
already tracks) before the existing P2S multi-AMS resolver. When
every entry is -1, the print uses ONLY the external spool, so
state.tray_now is promoted to 254.
Narrow on purpose: AMS-only [5] and mixed [5, -1] are NOT
overridden — we have no evidence H2S misreports mid-print swaps, and
trusting the firmware preserves correctness for users with multi-
filament setups. No-mapping prints (printer-screen start) fall
through unchanged.
Single failure on a printer with require_previous_success queue items
permanently skipped every downstream + every new item — the
_check_previous_success lookback always walked back to the original
failed row (skipped is excluded from the lookback), and no code path
could dismiss that failure.
Three pieces:
1. PrintQueueItem.gate_acknowledged Boolean column (default False).
SQLite/Postgres-safe ALTER, dialect-branched DEFAULT.
2. _check_previous_success skips rows where gate_acknowledged=True so
acknowledged failures walk past the lookback. Fresh post-resume
failures still gate independently.
3. POST /api/v1/queue/printer/{printer_id}/resume — gated on
QUEUE_UPDATE_ALL — acknowledges failed/aborted items for that
printer AND restores items where
status='skipped' AND error_message='Previous print failed or was
aborted' back to pending in one transaction. Returns
{acknowledged, restored}.
Frontend banner above the active Queue tab surfaces blocked printers,
fires a warning-variant ConfirmModal, and shows a precise toast on
success.
Round 2 (166e9f9e) fixed the stash-key mismatch, but @mkoreen's
2026-06-23 bundle showed BS's MQTT project_file arrived 85 ms past the
2.0 s wait timeout (FTP done 00:42:02.509, "No slicer options cached"
00:42:04.509, MQTT 00:42:04.594). Queue item was committed with
settings defaults; nozzle_mapping never made it onto the wire.
Three pieces:
1. _SLICER_OPTIONS_WAIT_TIMEOUT module constant, 2.0 -> 5.0 s. Covers
wireless / loaded-Pi jitter; one-time +3 s cost only for legacy
slicers that never send MQTT.
2. _RECENT_QUEUE_ITEM_TTL fallback: on_print_command retroactively
UPDATEs slicer-driven fields on a recently-committed queue item
when the event wait already gave up. Tracked via
_recent_queue_items dict (30 s TTL, evicted on every queue-add).
Gated on status='pending' so we never race the dispatcher.
Multi-plate covered via WHERE id IN (...).
3. Post-commit last-chance pop. Audit caught a race in (2): MQTT could
arrive during any await inside _add_to_print_queue (wait_for,
archive_print, db.flush, db.commit), and on_print_command would
stash data with no event consumer AND no _recent_queue_items entry
yet. After populating _recent_queue_items, _add_to_print_queue now
pops _slicer_print_options[file_path.name] one last time and
routes any hit through _restamp inline.
New setting "Auto-add unknown RFID spools" under Settings -> Filament -> Filament Tracking,
default ON for back-compat. When turned off, the backend stops auto-creating an inventory
record for an unknown RFID tag and instead broadcasts an unknown_tag WS event that pops
a global confirmation modal in the Bambuddy UI showing the printer / AMS-X label / slot /
material / colour. Add or Cancel; no nag on every MQTT push.
Backend
- Module-level _unknown_tag_last_broadcast dict dedupes per (printer, slot, tag). Set is
committed AFTER ws_manager.broadcast() returns so a crashed broadcast doesn't poison
the dedup and permanently silence the slot.
- Empty-slot MQTT push clears that slot's entry, so remove+reinsert reliably re-prompts.
- Successful matches via get_spool_by_tag / find_matching_untagged_spool / create_spool
also clear the entry so a future tag swap re-prompts.
- Tray data (tray_type, tray_color, tray_sub_brands, tray_count) shipped in the WS payload
directly so the modal renders the real material / colour instead of relying on the
React Query cache that lags the WS event by several seconds.
- Two new endpoints back the modal's confirm action:
POST /api/v1/inventory/spools/from-slot (INVENTORY_UPDATE)
POST /api/v1/spoolman/spools/from-slot (FILAMENTS_UPDATE)
Both look up the slot's tray data server-side and create + auto-assign atomically.
- Spoolman /from-slot now raises HTTP 500 when the slot-assignment INSERT fails instead
of returning success while the DB rolled back the binding.
- sync_ams_tray gained an optional auto_add_unknown_rfid kwarg (default True so existing
callers are unaffected); auto-sync and both manual sync routes thread the setting.
Frontend
- useUnknownTagPrompt hook listens for the unknown-tag CustomEvent, reads the tray fields
out of the event detail, and feeds a single-modal queue. No long-lived dismissed set;
the backend dedup handles spam suppression.
- UnknownSpoolModal wraps the existing ConfirmModal with a material + colour-swatch
preview block.
- Mounted in Layout.tsx alongside useSponsorPrompt so SpoolBuddy kiosk / login / setup
routes are excluded.
- getAmsLabel moved to utils/amsHelpers.ts; ConfigureAmsSlotModal.tsx and PrintersPage.tsx
both import the shared version (canonical AMS-A / HT-A / External labels).
- AppSettings TS interface gained spoolman_enabled, auto_add_unknown_rfid, spoolman_url
so the runtime cast in the hook is no longer needed.
- SpoolmanSettings.tsx gets a new toggle row in the Filament Tracking card, visible in
both built-in and Spoolman branches; auto-save + toast already wired.