The Virtual Printer binds 990 and 322, below 1024, which a service running
as a normal user may not do without CAP_NET_BIND_SERVICE. Without it the
rest of Bambuddy works and only the VP is dead -- sockets never open, the
slicer never finds the printer, and the sole trace is one journal line.
332a7c6ac added the line to install/install.sh in March under the heading
"Fix install.sh missing AmbientCapabilities". Three other places define the
same unit and none of them got it: the manual template, the combined
Bambuddy + SpoolBuddy installer, and the unit the wiki tells you to paste.
The wiki additionally claimed the capability was always included.
Also diagnose it. The VP diagnostic reported only that nothing was listening
on 990, which reads identically to a port conflict. It now checks CapEff for
the capability and names it as the cause -- but stays quiet when the port is
answering (an iptables REDIRECT is the documented alternative and that host
works) and when the capability is held (the port is down for another reason
and blaming this would misdirect). Skips where there is no procfs rather
than putting a systemd instruction in front of a macOS user.
Round-3 review of the "Save AMS mapping" PR.
The queue item's ams_mapping was set unconditionally, on the reasoning that
honouring the slicer's own pick is a correctness fix rather than a feature.
It is both. Storing a resolved mapping makes _ensure_ams_mapping return
early, so _compute_ams_mapping_for_printer never runs — and that function is
where prefer_lowest_filament lives, along with the AMS-filament-backup gate
that qualifies it (#1766), the inventory-remain overrides, and the per-slot
force-colour overrides. Every existing queue-mode VP pointed at a printer
would have quietly lost all of it on upgrade, without a setting to turn it
back on.
So save_ams_mapping now gates the queue item too, not just the archive
persistence. Off is exactly the old behaviour. The correctness case the PR
was written for — two spools of the same red PLA, and the slot the user
picked in the slicer thrown away — is still fixed, for anyone who asks for
it.
Force color match wins over it when both are on. Its only effect on a
fixed-printer item is the filament_overrides written onto the queue item,
and those are read inside the function a stored mapping skips, so the two
toggles sitting next to each other on the same card silently cancelled. The
dispatch now matches strictly, as asked, while the slicer's pick is still
saved onto the archive — that is what the toggle's name promises, and a
later reprint is a separate decision from this print. The queue-add fallback
applies the same rule to a request that carries force-colour overrides.
A mapping shorter than a plate's highest slot id cannot address that plate's
own slots, and _ensure_ams_mapping would have kept it anyway, since it only
rejects an all-unresolved one. Each plate now checks the length it needs and
falls back to a computed mapping if the array does not reach. Bambu Studio
sends a file-global array, so this normally never fires; it also means a
multi-plate Send All degrades safely if that ever stops being true.
The badges claimed more than they delivered. Both rendered whenever a saved
mapping existed, ignoring which printer it belonged to, while the tooltips
promised the reprint would reuse those exact spools — true only on the
printer the trays were resolved against. The queue row's flag is now
computed against that row's own printer, which is precisely when dispatch
reuses the mapping, and the archive card names the printer instead of
implying any of them will do. It hides itself when that printer no longer
exists. Retranslated in all 13 locales.
Frontend tests, which the PR had none of. The printer-scoping rule is now a
pure function rather than an inline expression, covered for the mismatched
printer, the no-printer-selected case that would otherwise compare undefined
against undefined, and malformed extra_data. The toggle's undo bookkeeping
is covered for unresolved slots, short mappings, and hand-made picks —
preserved when the toggle never wrote that slot, replaced when it did, which
is behaviour worth pinning either way.
Also reverts all three queue-mode switches when a save fails, not just the
new one; without it the card shows a setting the server rejected.
Round-2 review fixes for #2700.
Blocking: the toggle didn't actually gate the archive write. archive.py's
promotion fired for any print_data carrying ams_mapping, but bambu_mqtt's
request-topic interception captures ams_mapping unconditionally for every
print source (slicer-direct LAN prints included). Since main.py's
real-printer auto-archive path forwards the full MQTT payload as
print_data, every archive on any install — VP or not — grew
extra_data.slicer_ams_mapping. Fixed by replacing the print_data-sniffing
with an explicit `slicer_ams_mapping` param on archive_print() that only
the VP-queue path (already gated on save_ams_mapping) ever passes.
Blocking: a saved mapping could get reused on a printer it was never
resolved against — tray IDs only mean something relative to one printer's
AMS layout. extra_data.slicer_ams_mapping is now stored as
{mapping, printer_id} instead of a bare array:
- add_to_queue's fallback only fires when the reprint's target printer_id
matches the mapping's origin printer.
- The frontend's archiveAmsMapping only surfaces (and the Mapping button
only appears) when the print modal's selected printer matches too.
- A model-based VP (target_printer_id=None, no MQTT bridge to any real
printer) never stamps a mapping in the first place — there's no live AMS
layout for the slicer to have resolved tray IDs against.
Also from review:
- Multi-plate archives now get the Mapping button too (the per-plate
FilamentMapping loop was missing archiveAmsMapping entirely).
- Added coverage for the previously-untested late-MQTT archive patch path
(_restamp_recent_queue_item), including the model-based-VP skip case.
- usingArchiveMapping now also resets on printer change, not just
plate/archive (it already worked via the printer-scoping above, but is
now an explicit dependency too).
- The Mapping button's revert (OFF) now undoes only the slots it itself
set, not every manual pick in scope — matches the comment above it.
- Added a comment on why negative-value slots (external spool) are
skipped rather than cleared when applying a saved mapping.
Lets a reprint reuse the AMS slot the slicer itself picked, instead of
re-deriving one from the file's static type/color.
When a Print Queue VP has "Save AMS mapping" on, the slicer's own
live-resolved ams_mapping (from the project_file MQTT command) is
persisted onto the archive as extra_data.slicer_ams_mapping. A later
reprint can reuse it via a new "Mapping" button in the filament-mapping
panel — one click snaps every slot to the saved pick, click again
reverts to auto-match. Archive cards and queue rows get an "AMS mapping
saved" badge so it's visible beforehand. add_to_queue also falls back
to the saved mapping automatically when the caller sends no explicit
ams_mapping (e.g. a plain reprint with no per-slot edits).
The queue item's own ams_mapping (used for that dispatch) is still
captured unconditionally whenever the slicer provides it — that part is
a correctness fix, not gated behind the toggle. Only the archive
persistence for future reprints is opt-in.
Split out from the original combined PR per review: this half is
genuinely opt-in and low-risk (#2684). The dispatch-time validation
gate that keeps a stored mapping honest (#1308) changes behaviour for
every existing user and will land as its own PR.
Review fixes applied:
- _extract_slicer_ams_mapping_json: dropped the unreachable `v is None`
arm and rejected bool explicitly (isinstance(v, int) accepts bool).
- Translated the Russian docstring text to English.
- save_ams_mapping's model comment moved to a trailing comment on the
column line, matching the file's convention.
- usingArchiveMapping now resets when the plate or archive changes, so
the Mapping button can't read ON against a mapping it never applied.
- Translated "Click to change slot assignment" and "Re-read".
- add_to_queue's fallback is now called out explicitly in code comments
and covered by three new integration tests (fallback fires, explicit
mapping wins, unrelated extra_data doesn't false-trigger).
Closes#2684
Every slot of the A2L's AMS Lite rendered as "?" in Bambu Studio through the
Virtual Printer while Bambuddy's own AMS card was correct, and a filament set
by hand in Studio reverted about a second later.
The A2L reports its AMS Lite as physical unit id 16 but packs the slot presence
bits at base 24, so bambu_mqtt normalises the id to 6 at the ingest boundary and
every internal reader gets the right bits. The VP bridge is not downstream of
that: BambuMQTTClient._on_message fans raw payload bytes out to raw-message
handlers before parsing, so mqtt_bridge._on_printer_raw does its own json.loads
and still holds id 16. It then called the shared apply_tray_exist_bits, which
computed 16*4 = bits 64-67 -- never set -- concluded all four slots were empty,
and wiped tray_type / tray_color / tray_info_idx / tag_uid / tray_uuid / remain
from the copy sent to the slicer. That runs on every push, which is why a manual
pick could not survive the next 1 Hz cached-as-base report.
apply_tray_exist_bits now folds the unit id through normalize_am_unit_id, so 16
and 6 land on the same bit base whichever id the caller holds. The bridge's
cached ids stay physical on purpose -- Studio addresses the Lite as 16, sending
ams_get_rfid {ams_id: 16} through the VP -- so normalising the cache instead
would have broken the slicer's own command path.
Confirmed from the reporter's debug log, which shows the cleanup clearing slots
at bits 64-67 under the VP's log label. Before #2670 added the
0 <= ams_id <= 15 range guard this wiped the slots; after it, unit 16 fell out
of the guard and the A2L got no empty-slot cleanup at all -- two different wrong
answers, both fixed here.
Force color match dispatched onto the wrong PLA sub-variant: a job sliced
for White PLA Matte was treated as an exact match by printers loaded with
White PLA Basic or Silk+, because Bambu reports every variant as
tray_type "PLA" and the distinction lives only in tray_info_idx
(GFA00=Basic, GFA01=Matte, GFA06=Silk).
Two places dropped the field: the VP queue built each force override
without the parsed tray_info_idx, and _get_missing_force_color_slots
compared loaded trays on (type, colour) only.
Carry tray_info_idx into the override and require it to match when both
the override and a candidate tray have one; a blank idx on either side
(custom/third-party spools, older 3MFs) falls back to the historical
type+colour behaviour, so those setups are unaffected.
Bed levelling, flow calibration, and nozzle-offset calibration were on/off
only, so the sole way to run bed levelling was to force a full level before
every print. Bambu Studio has always offered a third "Auto" state that lets
the printer skip the calibration when it was done recently -- the state most
users actually want. Make these three options tri-state (off/on/auto),
defaulting to auto, and leave vibration/layer-inspect/timelapse as on/off
(Bambu Studio exposes no auto for those).
Wire encoding follows Bambu Studio's source exactly: each option sends a JSON
bool (true only for "on") plus a companion int -- off=0, on=1, auto=2. The
bool fields stay booleans (the #1478 H2S regression); only the companion int
widened from {0,1} to {0,1,2}. #1721's observation that stage 8/39 stays
queued when sending 2 is the auto contract (queued, skipped at runtime if
recent), not a broken "off".
- schemas: TriState = Literal[off/on/auto] with a BeforeValidator coercing
legacy bool / 0-1 / true-false so old clients and un-migrated rows validate
- model + migration: boolean columns -> String; SQLite via column affinity +
data backfill, PostgreSQL via ALTER COLUMN TYPE guarded on information_schema
(verified on both dialects); settings rows normalised true/false -> on/off
- MQTT: start_print takes the tri-state strings and emits the paired bool+int
- Virtual Printer: reconstructs the slicer's auto/on/off from the int companion
(auto_bed_leveling / extrude_cali_flag) in both capture paths
- frontend: CalibrationMode type; off/auto/on segmented controls in the print
dialog, queue bulk-edit, and Settings -> Workflow; calibrationMode_* strings
in all 11 locales
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.
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.
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.
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.
First-attempt fix (d196cfc5) was wrong about the cause. Real root,
traced via @mkoreen's BAMBUDDY_VP_DUMP_WIRE capture + 2026-06-21
support bundle:
mqtt_server.py:1296 was passing the slicer's bare subtask_name
(e.g. "Model_Name") into on_print_command, which stashed under
that key. _add_to_print_queue looked up under file_path.name
(the FTP filename WITH extension, "Model_Name.gcode.3mf"). The
two strings never matched. pop returned None, the 2s wait fired
against a key the stash side never signaled, every captured
slicer field silently fell back to settings defaults.
Affected EVERY Bambu Studio "Send" upload across EVERY model —
not just H2C nozzle_mapping. bed_leveling / flow_cali /
vibration_cali / layer_inspect / timelapse from the original
#1403 capture have been silently ignored since BambuStudio
started splitting subtask_name (bare) from file (with extension).
Unit tests passed because fixtures called on_print_command with
file_path.name directly, bypassing the broken caller.
Fix in manager.py::on_print_command: derive
stash_key = data.get("file") or filename and use it for both
_slicer_print_options and the event lookup. filename
(subtask_name) still flows unchanged to _schedule_finish_release
— push_status echoes it back as gcode_file / subtask_name and
the slicer matches against its own subtask_name there, so
re-routing that path was a separate regression I caught and
reverted mid-audit.
Also: nozzles_info field was a wrong guess in d196cfc5 —
BambuStudio never sends it (confirmed via wire capture). Drop
the capture, dispatch, schema, kwarg, and route paths. DB
column stays nullable so old rows still load; nothing reads
or writes it.
Diagnostic: DEBUG log when _add_to_print_queue finds no slicer
options after the 2s wait, including the looked-up key and the
actual cache keys present. Future stash/lookup mismatches will
be obvious from a log line instead of needing a wire capture.
Behaviour change worth flagging: users on Bambu Studio whose
slicer-side bed-leveling / flow-cali / vibration-cali /
layer-inspect / timelapse differ from Bambuddy's
default-workflow settings will see their slicer choices
honored now instead of silently overridden. Restores #1403's
original intent.
BambuStudio's project_file MQTT command for O1C2 (the H2C dual-
nozzle-rack variant) carries nozzle_mapping (per-filament physical
nozzle position IDs) and nozzles_info (per-extruder rack metadata).
The VP intake was dropping both, so the H2C firmware fell back to
"last matching nozzle type" auto-pick and ignored the user's
slicer choice — every HF print landed on R2, every standard print
landed on R4.
Carry both fields through the VP intake → queue item → MQTT
dispatch path. New nullable TEXT columns on print_queue, non-
branched ALTER (matches ams_mapping / filament_overrides
precedent). Dual-nozzle gate at start_print() keeps the fields
off single-nozzle dispatches. Fail-open on malformed JSON —
firmware auto-picks, never worse than pre-fix.
Stamps both fields on every plate in the multi-plate Send All
loop (#1697 / #1188 precedent).
ams_mapping2 still handles H2D/X2D dual-extruder routing
unchanged; this fix is scoped to the O1C2 rack-swap mechanism.
VP queue-mode multi-plate Send All
==========================================
BambuStudio / OrcaSlicer "Send All" of a multi-plate project uploads ONE
3MF containing every plate (one FTP STOR, single filename) — slice_info.config
inside the file lists N <plate> blocks with their own index metadata and
their own Metadata/plate_N.gcode payload. Pre-#1733 the VP queue path
called _extract_plate_id which returned only the FIRST plate index, and
_add_to_print_queue built exactly one PrintQueueItem from it. Plates 2..N
silently dropped on the floor. From the user's perspective: Send All of a
3-plate project produced 1 queue item, indistinguishable from a regular
single-plate Send, with no log line to explain the discrepancy.
The wire was confirmed against the live H2D-1 Proxy VP: the same file
ships whether the user clicked Send or Send All; the only intent signal
is the count of <plate> blocks inside slice_info.config.
Fix: replaced _extract_plate_id (-> int | None) with _extract_plate_ids
(-> list[int]). The list contains every <plate> block's index in order;
falls back to [1] when slice_info.config is missing / unparseable so the
single-plate case is preserved. _add_to_print_queue now loops over the
list and creates one PrintQueueItem per plate, with:
- plate-specific position = MAX(position) + iteration_number, so the
items inherit consecutive positions and the slicer's plate order
becomes the queue execution order.
- per-plate required_filament_types / filament_overrides via
extract_filament_requirements(file_path, plate_id) — the plate-aware
filter shipped with #1697 — so the scheduler's per-printer "Any X"
matching dispatches each plate onto a printer with the right
colours loaded for THAT plate, not for plate 1's filament set.
- shared archive_id across all plates (one upload = one archive row).
- the VP's auto_dispatch + manual_start posture inherited unchanged.
Net behaviour: single-plate Send hits the loop once → exactly today's
result (one queue item, plate_id from the slicer, one archive). Multi-
plate Send All of a 3-plate file → 3 queue items, plate_id 1/2/3,
consecutive positions, all referencing the same backing archive.
Archive delete cascades to queue rows
=============================================
Previously the soft-delete path (the default the trash-can button uses)
called _cancel_pending_queue_items which only flipped queue rows with
status='pending' to status='cancelled' while leaving every other status
alone AND leaving every row in the DB. The Send All multi-plate work
above made this much more visible: deleting an archive backed by N
queue items now had to clean up N rows, and what users saw instead was
N "cancelled" rows lingering in the queue history.
Backend:
- Replaced _cancel_pending_queue_items with _delete_related_queue_items
(db, archive_id) -> int. DELETEs every queue row where
archive_id = X regardless of status. Matches what the hard-delete
path already did via the ON DELETE CASCADE FK on
print_queue.archive_id — both paths now produce the same end state.
- Print history lives in PrintLogEntry (FK ON DELETE SET NULL) and is
untouched; Quick Stats / accuracy bands are preserved across both
delete paths.
- 409 guard on archives.py::delete_archive when any related queue
item is currently status='printing'. Both soft and hard delete are
gated; deleting the archive while a print is live would strip the
dispatcher's metadata trail (filament / plate / ams_mapping) out
from under the running print.
- New GET /archives/{id}/delete-impact endpoint returns
{related_queue_items: N, currently_printing: M}. Cheap, single
endpoint, deliberately NOT folded into the archive list response
so the much larger list endpoint isn't forced to run the same
query per row.
Frontend:
- ArchivesPage delete-confirm modal queries the new endpoint when the
modal opens (useQuery with enabled: showDeleteConfirm) and renders
an amber "N queue items linked to this archive will also be removed"
line when total > 0 AND printing = 0, OR a red "Cannot delete —
M queue items are currently printing" line when printing > 0
(confirm button disabled in that case so the user can't bonk the
409 on submit).
- ConfirmModal gained an optional confirmDisabled?: boolean prop —
isLoading was the only disable knob before; this adds the external-
precondition path.
- 2 new i18n keys (deleteQueueItemsWarning, deleteBlockedByPrinting)
translated across all 11 locales per feedback_translate_dont_fallback —
no English fallbacks.
No DB migration — the CASCADE FK was already in place; only the helper's
semantics changed.
VP bridges bound to a target printer (Proxy mode, Queue mode with
specific target) forwarded the printer's raw AMS push_status to the
slicer untouched. bambu_mqtt.py::_handle_ams_data applies a
tray_exist_bits-driven cleanup to Bambuddy's internal state
(promote empty slots to state=9, wipe stale tray_type / tray_color /
tray_info_idx / tag_uid / tray_uuid / remain) so the AMS card renders
empty slots as Empty, but the VP bridge cache never ran the same
cleanup. Net result on real hardware: a printer with 3 loaded
filaments and several previously-loaded-now-empty slots had Bambuddy's
AMS card render those slots correctly as Empty, but BambuStudio after
Sync painted them as phantom loaded filaments with stale color and
material from before the slot went empty.
Root cause: two consumers of the same payload, only one wired to the
cleanup. _handle_ams_data ran it on every push; mqtt_bridge.py::
_on_printer_raw merged the ams blob via _merge_ams_dict but copied
tray_exist_bits through as an opaque scalar without acting on it.
Fix: factored the bit-clear logic out of _handle_ams_data into a
module-level helper apply_tray_exist_bits(units, tray_exist_bits_str,
*, power_on_flag, log_label). Internal path replaced with a single
call. Bridge calls it after _merge_ams_dict on the merged ams dict,
before the merged state is stored as the 1 Hz cached-as-base source.
Shared shutdown guard kept on both sides: all-zero bits +
power_on_flag=False is the printer-off pattern (#765, would
propagate phantom empties on every reconnect); nonzero bits +
power-off is valid idle-printer state (#1365, X1C between prints)
and still applies. AMS-HT units (id >= 128) skipped on both sides.
Tests: new TestApplyTrayExistBitsHelper (10 cases) pins the helper
contract directly. 3 new bridge regression tests reproduce the
#1726 wire shape, the shutdown guard, and the AMS-HT skip on the
cached slicer-facing state. Existing internal-state tests for the
bit-clear logic (covers state=9 promotion, loaded-slot preserve,
genuine-removal-with-power-on) continue to pass against the
refactored path.
One pre-existing bridge fixture had an inconsistent tray_exist_bits
('3' for 2 AMS units each with slot 0 loaded — bit 4 missing). The
shared cleanup exposed it; corrected to '11' (bits 0 + 4) to match
real-printer wire shape.
Reported by @needo37 with full code-level analysis including the
suggested fix shape and the BAMBUDDY_VP_DUMP_WIRE diagnostic to
verify on a live system.
Two warnings polluting every A1 support bundle on healthy prints, both
unrelated to the timelapse-default behaviour the issue actually reports.
1. mqtt_bridge.py's post-bind nudge calls request_status_update on the
real printer's MQTT client to populate the bridge cache without
waiting for the next periodic pushall. The bind frequently races the
TLS handshake, especially on A1 firmware. Skip the nudge when
state.connected is False — the periodic pushall fills the cache
anyway. The WARNING in bambu_mqtt.py stays for the genuinely-
actionable callers (refresh-status API, bug reporter).
2. Post-finish SD-card cleanup (and the symmetric forced-timelapse dir
walk) used delete_file_async's bool return to drive a WARNING when
all candidates failed. A1 firmware self-cleans the SD card before
our cleanup runs — every candidate FTP-DELE returns 550, we burn
the retry budget, then WARN on a successful print. Introduce
DeleteResult.{DELETED,NOT_FOUND,FAILED} so the helpers only WARN
on real network/auth/transient failures. NOT_FOUND advances to the
next candidate without consuming the 2s backoff. User-facing delete
endpoint returns 404 on NOT_FOUND.
Right after the slicer picks a filament for the external spool (vt_tray, ams_id=255),
Bambu firmware pushes a partial vt_tray carrying just {tray_info_idx, tray_color} -
~18 fields shorter than the pushall shape the slicer expects. The #1622 round-4
per-field accumulate (da799447) only carried over prev keys NOT in new, so the
cached vt_tray was replaced wholesale with the 2-field partial. The next 1 Hz
cached-as-base push delivered the stripped dict and BambuStudio rendered the
external slot as invalid (color only, no tray_type / state / k / n / cali_idx /
nozzle_temp_*). Reload restored it because the reconnect-triggered pushall
re-seeded vt_tray, then the cycle repeated. AMS slots didn't suffer because
_merge_ams_dict deep-merged them.
Fix: for every top-level push_status key whose prev AND new are both dicts,
overlay incoming keys onto prev rather than replace. ams is excluded (already
deep-merged). The same shape protects device / online / upgrade_state / ipcam /
upload / net against future firmware partials. net.info IP rewrite is unaffected -
_rewrite_net_info_ips runs before caching and overlay lets the freshly-rewritten
list win over prev when present.
Bridge cache replaced prev state wholesale on each incremental, re-merging
only a 14-key allowlist. Capability/lifecycle fields (cali_version,
print_type, mc_print_stage, device, ...) drained out within one 1Hz tick,
greying out BambuStudio's Device-tab UIs (manage-calibration, AMS-slot
dropdown) once the cache thinned. Most P1S users miss it by timing — they
click Device tab while the cache is still fat from the connect pushall.
Switch to per-field accumulate matching bambu_mqtt.py's internal state
handler: prev keys carry over verbatim when not present in the incoming
push, new values overwrite when present. _merge_ams_dict for partial AMS
blobs unchanged (#1387 / #1371 regression guards stay green).
_SLICER_VISIBLE_STICKY_KEYS removed — new logic is a strict superset.
Round-2 cmd.jsonl from shaddowlink proves the bridge forwards both commands and
responses correctly: ams_filament_setting round-trips with result=success on P1S,
the cached push_status carries tray_info_idx=GFA11/tray_type=PLA-AERO/K-n/cali_idx
intact, and the visible "unload" symptom comes from the slicer's choice of
extrusion_cali_set (push K direct, P1S firmware rejects) vs extrusion_cali_sel
(select by id, both H2D and P1S accept). The open question is what makes the
slicer pick _set vs _sel — likely the info.get_version response Bambuddy
synthesises or the first cached pushall reply the slicer reads at connect.
Round 2 captured neither; the JSONL had slicer_to_bridge and printer_to_slicer
but no direction for the bridge's own synthesised replies.
Same BAMBUDDY_VP_DUMP_WIRE=1 flag now also appends a bridge_to_slicer line for
every bridge-synthesised reply (info.get_version answer, project_file ack,
on-demand pushall response). Capture is in _publish_to_report — the single
chokepoint — gated on a new log_event param; the 1Hz periodic push threads
log_event=False so the JSONL isn't flooded (~60 lines/min/VP) because
dump_wire already covers cache shape per tick.
Diagnostic-only, no data-path change. Default param preserves every existing
call site's behaviour.
The shape-of-payload dump shipped earlier rules out cache wipes —
shaddowlink's round-1 captures show AMS data reaches the slicer
byte-identical to what the printer sent. The remaining symptom
(picking a generic filament in archive mode "unloads" the slot) lives
on the command path, which the snapshot dump doesn't see: it writes
only the cached _latest_print_state and the periodic 1Hz push.
Add append_event() in _debug.py — same env flag, separate file at
<log_dir>/vp_wire/<vp>_cmd.jsonl. One JSONL line per event with UTC
iso timestamp, direction (slicer_to_bridge / printer_to_slicer), MQTT
topic, <channel>.<command> grep handle, and parsed payload. Wired at
two points: mqtt_server._handle_publish for slicer publishes (after
JSON decode so the trace matches what the bridge actually parsed) and
mqtt_bridge._on_printer_raw "everything else" branch for printer
responses (after serial rewrite so the trace matches what the slicer
sees on the wire). Pushall / get_version stay out — both are handled
locally and never round-trip through the bridge.
Bytes payloads get the same \x00-tolerance fix from #927 so
OrcaSlicer's C-string-null publishes parse cleanly; un-parseable
bytes fall back to {"raw": "..."} so every line stays valid JSON.
Add a BAMBUDDY_VP_DUMP_WIRE=1 escape hatch that writes the bridge's
cached push_status (in) and the 1Hz slicer-facing copy (out) to
<log_dir>/vp_wire/<vp_name>_<direction>.json, overwritten each tick.
#1622's symptom — empty filament dropdown in slicer's AMS slot details
for P1S/A1 but not H2D in non-proxy VP modes — needs visibility into
the actual wire bytes flowing through the bridge to bisect between
"cache is missing fields" and "_send_status_report strips them on copy."
The existing logs prove the bridge is bound and pushing at 1Hz, but
not what's in the payload.
Off by default, single env flag, single file per VP per direction
(bounded disk footprint), failures swallowed at debug so a broken
dump can never break the 1Hz loop. 21 tests pin the helper contract:
disabled-by-default, atomic writes, sanitized vp_name (no path
escape), per-call env check so toggling without restart works.
Internal code N9 (from BambuStudio resources/profiles/BBL/machine/Bambu Lab A2L.json),
serial prefix 26A19 (5-char, same shape as H2C's late 31B8B). Capabilities from
Bambu's official A2L specs page: linear rail, single FDM extruder + integrated
cutter/plotter, no Ethernet (2.4 GHz Wi-Fi only), low-rate chamber camera on
port 6000.
The BambuStudio profile's use_double_extruder_default_texture: true flag
describes two TOOL HEADS (FDM + cutter), not dual filament extrusion — A2L
must NOT be classified as dual-nozzle or AMS routing will target the deputy
slot and firmware rejects with 07FF_8012.
Registry updates: printer_models.py, firmware_check.py, virtual_printer/manager.py,
virtual_printer/mqtt_server.py, PrintersPage.tsx, SpoolBuddyAmsPage.tsx.
12 new test cases in TestA2LModel pin every dimension.
Round 1 (b6636053 + 4ffefa60) shipped the keepalive parser, 1.5x idle
disconnect per MQTT spec section 4.4, and a per-minute status-push
diagnostic. Reporter's follow-up pcap showed the round-1 logic was
correct as designed, but the actual root cause sits one layer down:
the same OrcaSlicer install that stays connected to a real Bambu P1S
indefinitely sends zero MQTT packets after the initial CONNECT /
SUBSCRIBE / pushall / get_version burst - no PINGREQ at all - so any
spec-compliant server disconnects it at keep_alive x 1.5.
Real Bambu firmware does not enforce section 4.4. The reporter's
identical Orca install holds idle sessions against real hardware on
the same network. Spec compliance was itself the regression.
Fix: after CONNECT/auth, drop the application-level read timeout
entirely (read_timeout = None) and set SO_KEEPALIVE on the underlying
socket so the OS TCP stack reaps dead connections within a few
minutes. The 60s pre-CONNECT cap is preserved - a client that opens
TCP but never sends CONNECT still gets reaped. Negotiated keepalive
is still parsed and now logged at INFO ("MQTT client X authenticated
(negotiated keepalive=Ys, idle disconnect disabled)") for support-
bundle visibility.
After this ships, OrcaSlicer should stay connected to the VP
indefinitely while idle and reconnect cleanly on real network drops.
The publish_json code -4 and -6010 errors reported in the original
thread were downstream of this disconnect and should also clear.
The 50000-51000 docker-compose port range spawned ~2000 docker-proxy
host processes (~3.5 GB RSS) under Docker's default userland-proxy.
The 1001-port pool was symptom treatment — collisions only matter for
multi-VP-on-shared-bind, but the cost was paid by every install.
Each VP now gets a non-overlapping 10-port slice computed from its id
(VP 1 -> 50000-50009, VP 2 -> 50010-50019, ...). Class constants are
gone; VirtualPrinterFTPServer takes passive_port_min/max instance args.
Wraps modulo PASSIVE_MAX_SLOTS = 100, with the existing 10-attempt
random retry as same-slot collision fallback.
Compose default narrowed to 50000-50029 (3 VPs). Proxy-mode VPs forward
the real printer's full range and stay on the separate TCPProxy
constants. Compose comment rewritten to acknowledge Linux multi-service
hosts as a primary bridge-mode audience and drop an over-stated
"confirmed by reporter" claim about userland-proxy=false.
Reporter on Bambu Studio 2.7.1.57 + X1C saw the Send modal stuck at
"Downloading" after sending to a Queue-mode VP. Delete-from-queue and
even Auto-Dispatch ON + a successful real print didn't release it.
Root cause: BS 2.7.x flipped the Send sequence from
MQTT project_file -> FTP upload -> done
to
FTP verify_job -> FTP .3mf -> MQTT project_file.
The #1280 fix sets gcode_state=FINISH in on_file_received (after the
FTP upload). Under the new order, the synthetic project_file ack in
_send_print_response then runs and overwrites _gcode_state back to
PREPARE. The 1 Hz cached-as-base push stream carries PREPARE forever,
the slicer never sees the FINISH transition it waits for, and the
modal sits stuck. Auto-Dispatch ON shares the cause: the real
printer's PREPARE->RUNNING->FINISH on the bridge gets masked by the
local _gcode_state override in _send_status_report.
Re-fire set_gcode_state("FINISH", filename, prepare_percent="100")
1.5 s after the project_file ack for every non-proxy mode (queue /
archive / review). The 1.5 s window lets the slicer see at least one
PREPARE push on the 1 Hz cycle so the transition reads as
PREPARE -> FINISH, matching what the slicer expects. Proxy mode is
exempt -- there the real printer drives the bridge state and a
synthetic FINISH would clobber a real PREPARE/RUNNING transition.
The scheduler cancels any in-flight timer when a new project_file
arrives so a retrying slicer doesn't end with two competing FINISH
timers. The pending timer is also cancelled on stop_server.
Printers added to Bambuddy by hostname/FQDN (e.g. p1s.fritz.box) hit
'invalid IPv4' in _ip_to_uint32_le, so the net.info[*].ip rewrite never
armed and BambuStudio Send went straight to the real printer instead of
the Bambuddy archive whenever the printer was powered on.
Add _resolve_target_to_ipv4(target): IPv4 pass-through, else
socket.getaddrinfo(target, family=AF_INET). AF_INET filter is load-bearing
because net.info[*].ip is uint32 LE and IPv6 can't round-trip. OSError
returns None so a transient DNS failure recovers on the next 30s refresh
tick via the existing not-armed throttle.
Apply the resolver to both the encode call and the host-interface picker
(which also assumes dotted-quad). Armed log line now carries
configured->resolved when they differ, so bad-DNS regressions stay legible
in 'docker logs'. The unresolvable not-armed reason now names the
configured value rather than parroting 'invalid IPv4', distinguishing
'DNS gave a v6 result' from 'user typed garbage'.
Root-caused by @Mape6; @TrickShotMLG02 confirmed the FQDN workaround
on the same release. Pre-0.2.4 these setups worked by accident because
there was no net.info[].ip rewrite at all.
_refresh_ip_encoding had 4 silent early-returns. When the rewrite
silently no-op'd on a user's setup, the only signal was the absence of
the "armed" INFO line, and diagnosing which path was firing meant
grepping the source.
Each path now emits one INFO line naming the specific reason. A
_not_armed_reason dedup field throttles to one line per state change,
so an idle unarmed bridge doesn't spam every 30s refresh tick. Cleared
on successful arm so regressions re-emit.
Not a fix for #1429 itself — the bridge logic is unchanged; this just
turns the silent failure into visible signal so the next "fix didn't
work for me" report can be triaged in one round-trip.
The #620 patch fixed the OpenSSL-3.x-strips-plain-RSA-AES-GCM cipher
mismatch on the printer-facing TLSProxy client context. The same fix
was never applied to the four other slicer-facing TLS contexts. On
hardened distros (Fedora / RHEL with update-crypto-policies, hardened
Alpine builds) where the system narrows DEFAULT to forward-secrecy
only, the slicer's ClientHello finds no overlap with what Bambuddy
offers and the handshake aborts with the slicer reporting code=-1
before any application data flows. The reporter pinpointed the missing
set_ciphers call in bind_server.py against the #620 lineage; the
audit-wide sweep here extends the same fix to mqtt_server.py,
tcp_proxy._create_server_ssl_context (the missing other half of #620),
and ftp_server.py.
For the three new contexts (bind / mqtt / proxy-server) the cipher
string is DEFAULT:AES256-GCM-SHA384:AES128-GCM-SHA256 — verbatim match
with the #620 client-side fix. For FTPS the original HIGH baseline is
kept (HIGH:AES256-GCM-SHA384:AES128-GCM-SHA256:!aNULL:!MD5:!RC4) so the
cipher set stays a strict superset of what shipped before — HIGH
offers ~58 suites DEFAULT doesn't (CCM / ARIA / CAMELLIA / DSS) that
no Bambu slicer is known to pick, but narrowing a compat surface
without proof would violate the existing don't-remove-compat-pinning
rule. TLS version pins (TLSv1_2 minimum across all four, TLSv1_2 max
on FTPS for the BambuStudio PSK-reuse compat) and verify-mode settings
are unchanged — only the cipher list is widened.
The original #1429 fix's _refresh_ip_encoding early-returned when
mqtt_server.bind_address was "0.0.0.0" or empty (the default for VPs created
without a dedicated bind IP). On a flat-LAN install that's the typical case,
so the encoding never armed, _rewrite_net_info_ips was a no-op on every push,
and the slicer kept following the real printer IP to its SD card. @Mape6
reported this on the 2026-06-02 daily that supposedly fixed the bug.
New helper _resolve_host_interface_for_target() consults the existing
network_utils.find_interface_for_ip() to pick the host interface in the
printer's subnet. _refresh_ip_encoding falls back to it when bind_address
is unspecified; an explicit bind IP still wins. INFO log line distinguishes
the two paths ("armed: ... (bind_address)" vs "(auto-resolved)") so future
bundles directly answer which IP the rewrite picked.
Tests: 4 new under TestBindAddressAutoResolve — rewrite arms via auto-resolved
IP at bind_address=0.0.0.0; stays disabled if no interface matches (no crash);
explicit bind_ip still takes precedence; helper returns None defensively when
find_interface_for_ip does.
#1429 (reported by @TrickShotMLG02, confirmed by @Mape6 on a flat single-LAN
that rules out subnet / mDNS-reflector theories): with the physical printer
off the slicer's "Send" landed in Bambuddy's archive; once the printer
powered on every subsequent "Send" went straight to the printer's SD card
and bypassed Bambuddy. Bundle analysis: mape6-before showed clean FTP
receive + archive lines, mape6-after had zero FTP attempts to Bambuddy
once the printer was online.
Cause: mqtt_bridge.py::_resolve_client encoded _target_ip_uint32_le /
_vp_ip_uint32_le ONLY on client-identity change and early-returned on
every refresh tick when the same client object was still bound. If
target_client.ip_address was empty at first bind (DB row stale, or client
constructed before SSDP refresh filled it in), the encoding stayed None,
the net.info[*].ip rewrite block was skipped, the cache filled with the
real printer IP, sticky-key preservation kept the poisoned net value
alive across every subsequent incremental push, and the slicer followed
the leaked IP. Only Bambuddy-restart-with-printer-off cleared it — the
workaround both reporters independently arrived at. Same shape on
multi-NIC printers (X1C, H2D Pro): the rewrite only matched entries
whose ip equalled _target_ip_uint32_le, so a secondary interface IP
Bambuddy never saw would leak through unchanged.
Bridge fix:
- _resolve_client calls a new _refresh_ip_encoding() on every refresh
tick, even when client identity is unchanged; self-heals once
ip_address becomes valid.
- _refresh_ip_encoding() sweeps the existing _latest_print_state when
encoding becomes valid for the first time. Without the sweep,
sticky-key preservation keeps the pre-arm poisoned cache alive
forever — incremental pushes that don't include net carry the bad
value forward.
- _rewrite_net_info_ips() rewrites EVERY non-zero net.info[].ip entry
that doesn't already equal the VP IP, not just entries matching
_target_ip_uint32_le. Multi-NIC printers stop leaking secondary
interfaces. Zero-IP placeholders are left alone so "active interface"
detection still works.
- INFO logging on encoding arm/update and on cache sweep so future
bundles directly answer "did the rewrite fire?".
Mode wire-value rename (#1429 follow-up, separate confusion source):
- Both reporters' support bundles showed mode: immediate while the UI
said "Archive"; @TrickShotMLG02 quoted: "I have no idea why it says
immediate in the support-info.json file. In the webui the printer is
set to archive". UI button "Archive" had always saved immediate, and
"Queue" had always saved print_queue. Canonical wire values are now
archive / review / queue / proxy, matching the button labels 1:1.
- New normalize_vp_mode() + VP_MODE_* constants in
models/virtual_printer.py; manager.py normalises on construction so
a legacy row read pre-migration still dispatches correctly.
- core/database.py::run_migrations rewrites existing virtual_printers
and settings rows; idempotent (re-runs are no-ops); identical SQL
under SQLite and Postgres.
- API routes accept both legacy and canonical on input, normalise
before storage. GET /settings/virtual-printer normalises on read so
the frontend's mode-button highlight works for stale legacy values.
- Three frontend VP components (VirtualPrinterSettings,
VirtualPrinterCard, VirtualPrinterAddDialog) switched click handlers
and type aliases to canonical; each got its own normalizeMode()
helper so a stale-cached settings payload still highlights the right
button. Two pre-existing `printer.mode === 'queue' ? 'review'`
legacy mappings in VirtualPrinterCard were the source of a test
failure caught mid-implementation where the new canonical 'queue'
was being mis-aliased back to 'review' and hiding the auto-dispatch
+ force-color-match toggles.
mode handler is NOT the dispatch bug: manager.py::_archive_file (the
handler for archive mode) doesn't dispatch to the physical printer.
The "files end up on the printer's SD card" symptom was the IP-leak
from the bridge cache. The mode rename is purely clarity / support-
bundle accuracy.
Two attacker-controlled strings were being joined to library_dir with no
resolve + containment check in the project ZIP import endpoint:
- linked_folders[*].name from the request's project.json
- per-entry zf.namelist() paths from the ZIP itself
An absolute path in either field collapsed the join (Path("/lib") / "/etc"
becomes Path("/etc") because pathlib discards the left side when the right
is absolute) and the next write_bytes landed wherever the attacker chose.
Adjacent finding from the routes audit: GET /archives/{id}/photos/{filename}
had NO validation on filename and FileResponse-served arbitrary paths -
the DELETE counterpart at least gated on the photos membership check.
Adjacent finding from the services audit: ArchiveService.attach_timelapse
wrote archive_dir / filename where filename ultimately came from a printer's
FTP listing (compromised-printer threat model) or the /timelapse/select
query param. A malicious printer that exposes a directory entry with ..
segments could write the timelapse outside the archive directory.
New backend/app/utils/safe_path.py::safe_join_under(parent, *parts) is the
single source of truth: rejects empty / null-byte / absolute parts up-front,
joins under parent, resolves both sides, asserts is_relative_to. Returns the
resolved canonical path on success, raises HTTPException(400) on escape, or
PathTraversalError when http=False (for service-layer callers that need to
match a non-HTTP return contract).
Wired into the import vectors, both archive photo handlers, and the
attach_timelapse service. The full audit sweep inspected every Path/Name
join in backend/app/api/routes/ AND backend/app/services/ - 25 route-layer
sites + 8 service-layer sites confirmed safe and tagged with
# SEC-PATH-OK: <reason> so future audits trust the inline guard at a glance.
Fifth CI backstop test_route_path_arithmetic_is_safe_joined_or_marked
AST-walks both layers and fails the build on any <dir-like>/<bare variable>
join that doesn't either route through safe_join_under or carry the marker.
The services layer is in scope because it receives values verbatim from the
routes AND from external sources Bambuddy has no control over (the printer
FTP-listing case above).
SECURITY.md gets a fifth rule + a fifth row in the CI test mapping table;
the rule now names the printer FTP-listing case explicitly so future
services-layer audits set the right expectation.
--------------
fix(library): suppress warning storm when bulk-uploading ZIPs of empty/stub STL files
Uploading a ZIP of stub or empty STL files (e.g. the 24-byte
"solid test\nendsolid test" shape) produced one WARNING per file in
stl_thumbnail.py::generate_stl_thumbnail. The warnings were technically
correct - trimesh returns a valid Mesh with zero vertices, the safeguard
matches, and the function returns None so the library entry is still
created without a thumbnail - but the volume turned a successful upload
into thousands of WARNING lines in the journal.
Two changes:
1. The per-file "Failed to load STL or empty mesh" message in
stl_thumbnail.py is now logger.debug instead of logger.warning. It's
a per-file content observation, not an actionable error; the caller
already handles None correctly. The branch now catches the rare
"large enough but trimesh still can't parse it" case, visible in
debug logs without spamming production.
2. New module constant MIN_USABLE_STL_BYTES = 200 (smallest binary STL
with one triangle is 134B, smallest ASCII ~150B; 200 is a safe floor
below any real STL). The three thumbnail call sites in library.py
(extract_zip_file, single-file upload, _backfill_external_stl_thumbnails)
pre-skip files below this size before calling generate_stl_thumbnail.
Stubs never enter the trimesh pipeline at all.
Behavior is unchanged for real STLs: any file >=200 bytes runs through
the existing pipeline, MAX_VERTICES still triggers simplification at
100k vertices for the 256x256 thumbnail render, large files still get
thumbnails.
------------
fix(stl-thumbnail): silence matplotlib first-import noise (writable cache + font_manager log level)
On first STL upload, three matplotlib-internal log lines surfaced:
WARNING [matplotlib] /opt/claude/.config/matplotlib is not a writable directory
INFO [matplotlib.font_manager] Failed to extract font properties from NotoColorEmoji.ttf
INFO [matplotlib.font_manager] generated new fontManager
The writable-dir warning fired because Bambuddy's $HOME isn't writable for
matplotlib's default config path; matplotlib fell back to /tmp/matplotlib-XXX
which lost the font cache on every host reboot, so font_manager rebuilt it
each cold start - producing another batch of INFO lines.
Fix is two small additions in stl_thumbnail.py before the matplotlib import:
1. New _configure_matplotlib_cache() sets MPLCONFIGDIR to
settings.base_dir/.cache/matplotlib (mkdir if missing) so the cache
persists across container restarts and the writable-dir warning never
fires. Respects an externally-set MPLCONFIGDIR so operators who chose
their own path aren't overridden. Best-effort with a debug fallback if
settings can't be imported or the mkdir fails.
2. logging.getLogger("matplotlib.font_manager").setLevel(WARNING) at module
import demotes the per-font INFO scan that fires when font_manager
builds its cache cold. Real font warnings (>= WARNING) still surface.
3 new tests: font_manager logger at WARNING after module import;
_configure_matplotlib_cache creates the directory under base_dir and sets
MPLCONFIGDIR; an externally-set MPLCONFIGDIR is preserved verbatim.
5516 backend tests green, frontend gates clean.
The 1 Hz status push was silent at INFO, so support bundles couldn't show
whether the push task was actually reaching a specific slicer connection.
Now ``_periodic_status_push`` emits one line per minute per connected
slicer ("1Hz status push: N pushes/min to <client>") and stays silent when
no slicer is attached. No behaviour change to the push itself — counters
are local to the task and reset every 60 ticks.
Motivated by the open follow-up on #1548: keepalive parser shipped (b6636053)
and the symptom moved 60 s → 90 s, but OrcaSlicer still disconnects on idle.
This adds the missing observability so the reporter's next support bundle
shows whether our outbound 1 Hz push is alive for the disconnecting
connection.
OrcaSlicer connects, exchanges pushall + get_version, then sits idle waiting
for status pushes from the (virtual) printer. The VP MQTT server's read
loop used `asyncio.wait_for(reader.read(1), timeout=60)` regardless of what
the client negotiated, and `_handle_connect` explicitly skipped the
keepalive field in the CONNECT payload, so every idle slicer connection was
torn down at exactly 60s.
- Parse the 2-byte big-endian keepalive from CONNECT; return it from
_handle_connect alongside the auth bool.
- Use 1.5x the negotiated keepalive as the per-packet read timeout per
MQTT spec sec 4.4. Treat keep_alive == 0 as no timeout (spec sec 3.1.2.10).
- Retain the 60s default for the initial read before CONNECT arrives, so
a TCP-connect-without-CONNECT still gets reaped.
- 7 new tests: 4 unit-level for the parser (success, opt-out=0, auth-fail
tuple shape, malformed CONNECT) + 3 integration-style for the read loop
(long keepalive survives the old 60s mark, short keepalive closes idle
in ~3s, PINGREQ resets the window so DISCONNECT decides the exit).
Two recurring virtual-printer support pains, both on the Virtual Printers
settings page.
Setup check: a stethoscope action on each VP card runs a pass/fail/warn/skip
checklist — VP enabled, services running, bind interface still exists, access
code set, target printer (proxy mode), and a live TCP probe of the FTP / MQTT
/ discovery ports on the bind IP. start_server swallows per-service bind
errors, so a service object can exist while nothing is listening; probing the
bind IP from outside is the only reliable signal and it catches the common
"VP not visible in the slicer" bind-IP-conflict and stale-interface cases.
Slicer certificate: virtual printers present a TLS cert signed by a shared CA
the slicer must trust. Until now users had to docker exec in and cat
bbl_ca.crt. A "Slicer certificate" row on the settings card now offers Copy
and Download (bambuddy-virtual-printer-ca.crt) plus the SHA-256 fingerprint.
GET /virtual-printers/ca-certificate returns only the public certificate; the
CA private key never leaves the backend. The CA is generated on demand so the
button works before the first VP is enabled.
Backend:
- services/virtual_printer/diagnostic.py — run_vp_diagnostic + port probes
- schemas/virtual_printer.py — VPDiagnosticResult
- CertificateService.get_ca_certificate_info() + manager helper
- routes: GET /virtual-printers/ca-certificate, /{vp_id}/diagnostic
Frontend:
- VirtualPrinterDiagnosticModal.tsx; stethoscope button on VirtualPrinterCard
- caCert row on VirtualPrinterList; utils/clipboard.ts (shared copy w/
non-secure-context fallback + downloadTextFile), de-duplicating the
existing FQDN-copy logic
- vpDiagnostic.* + virtualPrinter.caCert.* across all 9 locales
9 backend unit tests + 4 route integration tests + 6 frontend tests.
Backend ruff clean, frontend build clean, i18n parity green.
Reporter sliced in OrcaSlicer with timelapse on, sent the job to a VP
queue, started from the queue, and got no timelapse video. Their
dispatch chain itself was correct (queue item -> scheduler -> MQTT
command honors `timelapse`); the gap was at queue-add time.
The VP's `_add_to_print_queue` reads `default_timelapse` (and the four
other print-option settings) from the workflow settings card. That was
introduced in #1235 to stop column-level defaults from winning. But it
also discarded the slicer's actual choice carried on the MQTT
`project_file` command, which all the slicers (Studio / Handy / Orca)
ship as `timelapse: true|1`. Result: a user with the new-install value
`default_timelapse=false` had to either flip the global setting or
edit every queue item by hand, even though their slicer's "Print
options" UI clearly said "record timelapse".
Investigation went wider than #1403 because Martin's hypothesis was
"the print options modal isn't respected either." Cross-checking
86 captured P1S `project_file` commands across the support packages
shows 46 from the queue scheduler and 33 from background_dispatch
emitting `"timelapse": true` correctly to real printers - the modal +
re-print path is intact end-to-end. The slicer-side gap was the only
real bug. Two unrelated dead-code issues turned up in the same dig and
are folded in below.
Fix (VP queue inheritance)
- `on_print_command` in the VP manager now stashes the slicer's
project_file dict keyed by filename, then signals an asyncio.Event.
- `_add_to_print_queue` checks the dict first; if empty, creates the
event and waits up to 2 s for it before reading the settings
fallback. Each option flows through per-field - slicer value wins
if present, else the existing settings default (so users who
explicitly set `default_timelapse=true` in their VP workflow card
still get that on slicers that don't send a print command).
- MQTT field naming preserved exactly: `bed_leveling` (single L) on
the wire stays mapped to `bed_levelling` (double L) on the Bambuddy
column. Integer 0/1 from H-family slicers and bool true/false from
P1/X1 slicers both coerce via `bool()`.
- Capture is gated on `mode == "print_queue"` so immediate / review /
proxy modes keep their pre-fix no-op `on_print_command` and don't
accumulate stashed entries over the VP's uptime.
- Wait is also skipped when there's no MQTT server attached
(`self._mqtt is None`), so unit tests that invoke
`_add_to_print_queue` directly don't pay the 2 s tax.
- Capture is consumed on use so the dict stays bounded.
- `printer_manager.get_status(...).get(...)` against a `PrinterState`
dataclass that has no `.get()` method.
- Every print option discarded (timelapse, bed_levelling, AMS mapping).
The route 500'd before ever reaching the printer. Rewritten to mirror
`POST /print-queue/{item_id}/start`: clear `manual_start=False` on the
next pending queue item and let the scheduler dispatch with the
queue's stored options intact. Response shape preserved.
Side-bug b: vibration_cali default drift in background_dispatch
- `ReprintRequest.vibration_cali` and `FilePrintRequest.vibration_cali`
both default to `True` (matches Bambu Studio behavior for X1/P1).
- Both `_process_job` call sites read
`job.options.get("vibration_cali", False)`.
Cosmetic today because the frontend always sends the field, but a
latent landmine for any future caller that bypasses the schema. Both
sites flipped to `True`.
Reporter vmhomelab ran a Print Queue VP against a P1S, opened
BambuStudio, and saw only the External Spool. Toggling Auto-Dispatch
(which restarts the VP) made AMS briefly appear, then it reverted to
defaults. Proxy Mode worked fine.
The earlier #1371 sticky-keys fix only handled one of two firmware
incremental-push shapes: it preserved cached `ams` when the incoming
push OMITTED the key entirely. P1S firmware (01.09.01.00) instead
sends incrementals with the `ams` key present but the inner `ams.ams`
array stripped — `{ams_status: 1, humidity: 2}` rather than
`{ams: [...], ams_status: 1}`. To the existing "key present? leave it"
check that read as "no need to preserve," so the bridge cache got
overwritten with the stripped blob, the slicer's next 1 Hz read saw
`ams` with no unit list, and BambuStudio fell back to its "no AMS"
default render. Toggling Auto-Dispatch restarted the VP and got a
fresh pushall through; the next P1S incremental stripped it again.
H2D rarely trips this because its incrementals typically don't carry
`ams` at all, so #1371 alone was enough — which is why H2D users
(including the project owner) didn't see the bug while P1S/A1 users do.
Fix: deep-merge the `ams` key inside the bridge cache. Mirrors the
structure Bambuddy itself already does in
`bambu_mqtt.py::_handle_ams_data` — scalar fields take the new value,
but the `ams.ams` array is merged unit-by-unit by `id`, each unit's
`tray` array is merged tray-by-tray by `id`, and units / trays the
incremental doesn't mention survive intact from the cached full
state. A tray-targeted incremental during a print
(`{ams: [{id: 0, tray: [{id: 0, state: 11}]}]}`) now updates that one
tray's state without dropping the other trays' tray_type / tray_color.
Helper added as `_merge_ams_dict` next to `_ip_to_uint32_le`, called
from the existing sticky-keys block when both prev and new carry the
`ams` key as dicts. Other sticky keys (vt_tray, net, ipcam,
lights_report, ams_extruder_map, mapping) keep the prior absent-only
preservation; only `ams` has the multi-shape partial problem worth
the merge complexity.
The bridge cache replaced _latest_print_state wholesale on every
push_status arrival. Bambu firmware sends full pushall responses
(with AMS/vt_tray/net.info/lights_report) on reconnect / pushall
requests, but ~1 Hz incremental updates with only the fields that
changed. The first incremental push after a pushall therefore wiped
AMS info from the bridge cache, and slicers reading the cache (via
the VP's 1 Hz status push) saw a stripped-down state with no AMS
visible until the next pushall — typically only on a manual printer
power-cycle.
Preserve a small set of slicer-visible sticky keys from the previous
cache when the incoming push doesn't carry them: ams, vt_tray,
ams_extruder_map, mapping, net, ipcam, lights_report. Mirrors the
same pattern Bambuddy uses for its own internal state.raw_data.
Real-printer prints broadcast archive_created from the MQTT print_start
handler, which the Archives page listens for to invalidate its query
cache. The VP file-receive paths created the archive in the DB but
never emitted the event, so the new card only appeared after a tab
switch triggered refetch-on-focus.
Added a small _broadcast_archive_created helper on VirtualPrinterInstance
and called it from _archive_file (immediate mode) and _add_to_print_queue
(queue mode). Review mode is unaffected — it creates a PendingUpload,
not a PrintArchive. Broadcast errors are swallowed at debug level so a
transient WebSocket issue can't break the file-receive flow.
Bambuddy's VP supports two slicer flows: Send (file upload only — what
queue/immediate/review modes are designed for) and Print (file upload
+ start-print, intended for proxy mode). When a user clicks Print
against a non-proxy mode the VP must still respond gracefully — the
file is fine to receive, just the start-print never happens. Instead
the slicer wedged at "Downloading...(0%)" and blocked the next
dispatch with "The printer is busy with another print job".
Cause: on_file_received transitioned gcode_state PREPARE -> IDLE
directly. Print-flow slicers watch the state cycle and only release
their in-flight-job lock on PREPARE -> ... -> FINISH (or FAILED).
PREPARE -> IDLE looks like "printer abandoned my job" and keeps the
prior job pinned in the slicer's memory.
Fix: transition PREPARE -> FINISH with prepare_percent=100. The 1-Hz
periodic status push broadcasts the new state to every connected
slicer within a second. Send-flow slicers don't watch this state so
the change is a no-op for them; Print-flow slicers see the FINISH
they were waiting for and unwedge.
Prints sent from a slicer to a VP in print_queue mode arrived in the
queue with bed_levelling / flow_cali / vibration_cali / layer_inspect /
timelapse set to the SQLAlchemy column defaults, ignoring the user's
workflow page settings entirely. The manual POST /print-queue endpoint
reads these from the request body (frontend pulls them from settings
before submitting), but manager._add_to_print_queue constructed the
PrintQueueItem without touching any of those fields.
Read default_bed_levelling and the other four settings via get_setting
and pass them explicitly. _bool_setting helper handles the None ->
AppSettings default fallback.
Slicer "Send to printer" worked on 0.2.3.2 with a queue-mode VP and
started failing on 0.2.4b3 with BambuStudio's generic "storage needs
to be inserted before send to printer" error. Multiple users
reported it across P1S, P2S, Docker bridge, macvlan, and host
networking. @rtadams89's debug-level support archive showed the
smoking gun: slicer establishes MQTT TLS, gets pushall +
get_version, then never opens an FTP connection — pre-flight
rejects before any data transfer.
The 0.2.3.2 synthetic stub baked in three SD/storage indicators
that BambuStudio's "Send" pre-flight reads: home_flag with bit 8
(HAS_SDCARD_NORMAL, 0x100), sdcard=True, and a storage:{free,total}
block. The 0.2.4b3 cached-as-base slicer-mirror (7dea33d0) passes
the live target's push_status through with only an IP rewrite — if
the real firmware doesn't report those fields (P1S/A1 with no SD
card, older field shapes, confirmed on P1S firmware 01.10.00.00),
the slicer sees "no storage" and aborts. H2D and X1C reproductions
worked because those firmwares do report the indicators.
In _send_status_report's cached-as-base branch, after copying the
cache and applying the existing protocol/upload-state overrides:
- home_flag |= 0x100 (preserves any other bits the real printer set)
- sdcard = True (force-set even when real says False)
- storage = setdefault(...) (only fills in if missing — real values
pass through unchanged when the printer reports them)
For VP usage the slicer uploads via FTPS to Bambuddy's filesystem
at /app/data/virtual_printer/uploads/<vpid>/; the printer's actual
SD card is irrelevant on that path, so forcing "storage available"
is correct for the queue / immediate / review modes the
cached-as-base path covers.
The Tailscale toggle was supposed to obtain a publicly-trusted Let's Encrypt
cert via `tailscale cert` so users wouldn't need to import Bambuddy's CA into
the slicer. End-to-end testing showed this was always going to fail:
- Bambu Studio and OrcaSlicer refuse hostname input in the Add Printer
dialog (IP-only).
- Their printer-MQTT trust path validates only against the bundled BBL CA
store (`printer.cer`), NOT the system trust store. Confirmed against
ClusterM/open-bambu-networking's clean-room reimplementation:
`mosquitto_tls_set(BBL_CA)` + `verify_peer=1` + `tls_insecure=true` —
chain validation against BBL CA only, hostname check intentionally
skipped (because Bambu's printer cert CN is the device serial).
- LE certs don't chain to BBL CA, so the slicer rejects with the
well-known "-1" before any hostname/IP logic runs.
The cert-import step is unavoidable; LE provisioning was dead code for slicer
connections. Pivot:
- Toggle stays as an informational marker — when ON, the VP card surfaces
the host's Tailscale IP + MagicDNS hostname so users know what to paste
into the slicer.
- Cert is always self-signed (signed by `bbl_ca`).
- Tailscale exposure is via the existing bind_ip dropdown, which already
includes `tailscale0` IPs.
- Tailscale's role is strictly network reach — same trust burden as LAN.
Backend cuts:
- `tailscale.py`: `provision_cert`, `ensure_cert`, `cert_needs_renewal`,
`_FQDN_RE`, `_HTTPS_DISABLED_RE`, `TS_CERT_EXPIRY_THRESHOLD_DAYS`,
`cryptography` import. Keep `get_status` and `TailscaleStatus`.
- `certificate.py`: `ts_cert_path`, `ts_key_path`, `use_tailscale_cert`.
- `manager.py`: `tailscale_fqdn` field, `_cert_renewal_task`,
`_cert_restart_task`, `_cert_renewal_loop`, `_restart_for_cert_renewal`,
`_cancel_renewal_task`, `_cancel_restart_task`. Simplify
`_resolve_cert_and_advertise` to a sync method that just generates the
self-signed cert. Drop `tailscale_disabled` from the change-detection
diff (toggle is informational — no service restart needed).
- `routes/virtual_printers.py` + `routes/settings.py`: drop the
`tailscale_not_available` 409 guard on toggle-enable.
Frontend cuts:
- `VirtualPrinterCard.tsx`: FQDN/IP display sourced from
`multiVirtualPrinterApi.getTailscaleStatus()` (host-level) when toggle
is ON, instead of `printer.status.tailscale_fqdn` (cert side-effect,
no longer populated). Drop the `tailscale_not_available` toast handler.
- `api/client.ts`: drop `tailscale_fqdn` from the VP status type.
- i18n: rewrite `tailscaleDisabled.description` in all 8 locales to drop
the "no cert import" promise. Remove `toast.tailscaleNotAvailable` key.
Docs:
- Wiki `features/virtual-printer.md`: rewrite the entire Tailscale section
— remove the LE-cert + HTTPS-Certs-toggle + tailscale-cert-operator
steps, document the toggle as informational, keep the Docker socket
mount + LXC TUN troubleshooting (those still apply for daemon
reachability).
- README: drop "the Tailscale benefit here is the tunnel, not cert-import
elimination" framing in favour of "surfaces the IP for paste into
slicer; CA import unchanged because BBL CA store, not system trust
store, is what gets validated".
Tests:
- `test_tailscale.py`: reduced to surviving `get_status` cases (binary
missing, command fails, success, empty DNSName, malformed JSON).
- `test_virtual_printer.py::test_sync_from_db_restarts_on_tailscale_disabled_change`
→ `test_sync_from_db_does_not_restart_on_tailscale_toggle` (toggle is
informational; `remove_instance` must NOT be called).
- `test_virtual_printer_api.py::TestVirtualPrinterTailscaleGuardAPI` →
`TestVirtualPrinterTailscaleToggleAPI` (single test asserts both
directions succeed and daemon is never consulted).
- `VirtualPrinterCard.test.tsx`: mock now stubs `getTailscaleStatus`;
FQDN-copy block drives data through that query.
DB column `tailscale_disabled` is kept (persists toggle state) — Postgres-
safe column drop is harder; future cleanup can remove if the toggle goes
away entirely. LE cert files on disk (`virtual_printer_ts.{crt,key}`) are
left in place per VP — harmless residue, manual cleanup if desired.
Verified: ruff clean, 2484 backend unit tests pass, 17 frontend VP-card
tests pass, frontend build succeeds, live service restart confirms VPs
serve `issuer=CN=Virtual Printer CA` on the Tailscale interface — slicer
trusts the user-imported bambuddy CA and skips hostname checks, so MQTT
connection succeeds end-to-end.
In non-proxy VP modes (Immediate / Review / Print Queue), the slicer now
sees real AMS / FTS / nozzle / k-profile state from the target printer
and streams the live camera — full slicer-as-remote functionality without
giving up Bambuddy's queue / archive / dispatch features.
Architecture (cached-as-base, single source of truth). The bridge caches
the latest real push_status and info.get_version response from Bambuddy's
existing per-printer MQTT subscription — no second session on the printer,
firmware in-flight budget unaffected (#1164). _send_status_report serves
a near-byte-identical copy of the cached push with only the upload-state-
machine fields overridden. Command responses (extrusion_cali_get, AMS
write acks, xcam) fan out raw — they carry sequence_ids the slicer is
waiting on. Slicer-issued commands forward to the printer except
project_file / gcode_file, which still terminate locally because the file
lives on Bambuddy. Camera is a raw TCPProxy on bind_ip:322 → printer:322,
same approach proxy mode uses.
Field-shape gotchas pinned in the bridge module's docstring and the
new test file:
- Real Bambu pushes use json.dumps(indent=4) wire format. Compact JSON
fails BambuStudio's Send pre-flight silently.
- net.info[*].ip is the FTP destination IP (little-endian uint32).
Without rewriting to the VP bind IP, the slicer FTPs straight to
the real printer.
- upgrade_state.sn rewritten to VP serial; AMS-hardware sn fields
(n3f/0.sn etc.) left alone.
- ipcam.rtsp_url passes through unchanged; BambuStudio overrides the
URL host with the device IP it bound on, so :322 lands on the VP's
TCPProxy.
- extrusion_cali_get must forward; answering it locally hides the
user's stored per-filament k-profiles.
Setup nuance for camera: the VP's access code must match the target
printer's because the slicer authenticates RTSPS with whatever access
code is in its profile. MQTT and FTP work either way.
Tested e2e with BambuStudio and OrcaSlicer against H2D (dual-nozzle,
AMS 2 Pro + AMS HT) and X1C across all three non-proxy modes — sync,
send, k-profile lookup, AMS configuration from slicer, and live camera
all work. Proxy mode is untouched: SlicerProxyManager owns its own
proxies and never instantiates SimpleMQTTServer or MQTTBridge.
25 new tests in backend/tests/unit/test_vp_mqtt_bridge.py cover lifecycle,
caching, identity / IP rewriting, wire format, slicer→printer routing,
and the LE-uint32 IP encoder against the real H2D capture value.
Edward's diagnosis was exact: the manual /print-queue/ POST extracts
filament requirements from the 3MF and writes
required_filament_types + filament_overrides + ams_mapping onto the
queue item, but the VP queue-mode write path skipped all of that.
Net effect: scheduler reached its model-only-matching fallback and
auto-dispatched onto whatever printer was free regardless of loaded
colour.
Extract the scheduler's existing _get_filament_requirements 3MF
parser into a shared helper so the VP path can reuse it. VP's
_add_to_print_queue now populates required_filament_types
unconditionally (cheap; helps the scheduler reject obvious type
mismatches) and writes filament_overrides with force_color_match:
true per consumed slot when a new per-VP queue_force_color_match
toggle is on. Default off to preserve current behaviour for
upgraders.
UI: new toggle on VirtualPrinterCard, mode-gated to print_queue,
mirroring the existing auto-dispatch toggle. i18n: en + de
translated, other 6 locales seeded with English copy.
Schema: one nullable column on virtual_printers
(queue_force_color_match BOOLEAN, default 0/FALSE).
11 new backend tests (8 for the extracted parser, 3 for the VP
write path) + 6 new frontend tests (toggle render gating, default
state, click posts queue_force_color_match in update body).
Existing scheduler tests pass against the refactored helper.
README, CHANGELOG, website features page, and wiki virtual-printer
page all updated.
@smandon retested the original #1152 fix on the latest daily and surfaced
two distinct holes:
1. ``Path(name).stem`` only strips the *last* suffix, so Bambu Studio's
default ``Plate_1.gcode.3mf`` exports landed in the archive UI as
``Plate_1.gcode`` — never the bare ``Plate_1`` the user expected.
2. The pending-uploads review card always showed the raw FTP filename,
while the eventual ``PrintArchive.print_name`` resolved from the 3MF's
embedded title (or, with the toggle on ``filename``, the stripped stem).
Net effect: same upload showed two different names depending on which
view you were looking at, with no way for the toggle to flip both
views in lockstep.
Three changes:
- ``resolve_display_stem`` helper in ``services/archive.py`` strips
``.gcode.3mf`` / ``.3mf`` / ``.gcode`` (case-insensitive). Applied at
the archive-creation site so ``Plate_1.gcode.3mf`` → ``Plate_1`` for
every flow that produces a ``PrintArchive`` row.
- ``PendingUpload.metadata_print_name`` (new nullable column) is
populated at FTP-receive time by peeking at the 3MF's embedded title
via the existing ``ThreeMFParser``. Read happens once per upload —
the list endpoint then doesn't have to reopen each 3MF on every
render. Parser failures are swallowed and the column stays NULL;
the response model gracefully falls back to the stripped filename.
- ``PendingUploadResponse.display_name`` is a computed field that
mirrors ``archive_print``'s exact precedence — ``filename`` toggle
→ stripped stem; ``metadata`` toggle (default) → cached title or
stripped stem. The frontend's review card reads it (with
``upload.filename`` as a defensive fallback) and surfaces the raw
FTP filename via tooltip so users can still inspect what arrived.
Migration is one idempotent ``ALTER TABLE pending_uploads ADD COLUMN
metadata_print_name VARCHAR(255)`` (Postgres/SQLite-safe). Pre-migration
rows have NULL and degrade to filename-stem behaviour without any
operator action.
Tests: 14 unit tests in ``test_archive_display_stem.py`` covering the
canonical normalisation rules (Bambu Studio default name, mixed case,
dots-in-the-middle, edge cases like ``.gcode.3mf``-only, full-path
inputs); 6 integration tests in ``test_pending_upload_display_name.py``
pinning the response contract (default toggle uses metadata title when
present, falls back to stripped stem when absent, ``filename`` toggle
overrides metadata, ``filename`` toggle still strips the double suffix,
``GET /{id}`` exposes the same field, whitespace-only metadata behaves
like absent); 3 frontend tests in ``PendingUploadsPanel.test.tsx``
pinning the review card's render path (resolved name shown, fallback
to filename when display_name is empty, raw filename available via
tooltip). Full backend suite: 3598 passed; frontend build clean; no
regressions in any flow that previously processed ``.3mf`` /
``.gcode`` / non-3D filenames.
Slicer-uploaded archives picked up their display name from the 3MF's
embedded print_name (the creator-baked title); users who renamed a job
in BambuStudio's "Send to printer" dialog never saw that name surface
because the FTP filename was only used as a fallback when metadata was
empty.
Settings -> Virtual Printer now exposes an Archive name source toggle
(Metadata / Filename, default Metadata) that flips precedence in
ArchiveService.archive_print via a new prefer_filename_for_name param.
All four VP-sourced archive paths read the new
virtual_printer_archive_name_source setting and forward the flag:
_archive_file, _add_to_print_queue, POST /pending-uploads/archive-all,
POST /pending-uploads/{id}/archive.