The PR gate reported four new alerts, all in test code. A fixture's
/tmp/x.3mf is a column value the migration under test UPDATEs, not a
path anything opens. Two f-string statements interpolate column names
from a literal list declared two lines above, with the id bound - a
column name cannot be a bind parameter, which is why it is written into
the string at all. The two joins move onto their own lines because a
trailing marker would have taken line 64 past the 120-character limit.
The fourth is a near miss rather than a finding: the line below it
already carries the marker, as do four other wildcard sites in the same
file. The wildcard is what that test exists to assert about.
BambuStudio reads its FTP upload destination out of net.info[].ip in the
MQTT status, and the bridge fills that field from the VP's bind address.
On Docker bridge networking the two are different machines' worth of
address: slicers reach Bambuddy on the host's LAN IP, the container binds
something like 172.24.0.2, and that private address is what the slicer was
handed -- so it opened an FTP connection to an address that does not exist
on its network and the send stalled around 10%.
VIRTUAL_PRINTER_ADVERTISE_ADDRESS supplies the address slicers actually
use, taking precedence over the bind address and over the same-subnet host
interface the bridge falls back to. The armed log line names its source, so
(VIRTUAL_PRINTER_ADVERTISE_ADDRESS) against (bind_address) tells an
operator whether the variable reached the container at all, and the
not-armed diagnostic now names it as the remedy -- a bridge-network install
that has not set it is exactly the one that cannot auto-resolve either, and
until now saw only that nothing worked.
An environment variable rather than a change to how the advertised address
is resolved, which is the decision worth recording. The VP already has a
"Network Interface Override" field, and reading it here is the smaller
patch, but it feeds SSDP and the certificate SANs only: honouring it would
silently move the upload destination on every install that has one set --
the multi-NIC, VLAN and Tailscale setups, which are the ones most likely to
have been arrived at by hand and least likely to survive being
second-guessed. Unset, this changes nothing, and a test pins that.
A value that is not a dotted-quad IPv4 is refused with one warning naming
it and the previous address is used instead. That direction is deliberate:
declining to rewrite would put the real printer's IP back in front of the
slicer, which is the leak the rewrite exists to close, so a typo must not
be able to reopen it. 0.0.0.0 counts as unset and whitespace is stripped,
for values pasted into a compose file.
Host and macvlan networking need none of this and stay what Virtual Printer
is developed against. The variable removes one blocker; it does not make
bridge mode equivalent. The wiki said in three places that the host address
could not be discovered at all, which is no longer true, so those now
describe the variable and keep the recommendation.
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.
After #2594 every empty-slot clearing path skipped AMS-HT units, so a removed
HT spool never cleared on the printer card while Bambu Studio correctly showed
Empty. Root cause: the HT presence bit is packed as a single consecutive bit in
tray_exist_bits at 16 + (ams_id - 128) (HT-A=16, HT-B=17, ...), not the regular
ams_id*4 position -- so the bitmask cleanup, which skipped id>=128, never
touched it. The HT's state field is firmware-variant (loaded reports 11 on H2D,
9 on the #2594 firmware) and it keeps echoing stale tray_type after removal, so
the bitmask is the only reliable, firmware-independent signal. Confirmed against
a live H2D capture (loaded=0x10f7f, empty=0xf7f) and the OrcaSlicer
DevFilaSystem.cpp reference (is_exists = tray_exist_bits >> (16 + (ams_id-128))).
- apply_tray_exist_bits: handle AMS-HT (128-135) at 16+(ams_id-128) instead of
skipping it; clears an empty HT and, because a loaded HT keeps its bit set,
never wrongly clears one (keeps the #2594 fix intact). Ids outside the known
regular (0-15) and HT (128-135) ranges are left untouched rather than guessed.
- Build the AMS change-hash from the merged state, not the raw payload, so a
removal signalled only by the bitmask (firmware still echoing tray_type) still
flips the hash and fires on_ams_change to unbind the spool_assignment row.
- Emit the exists presence bit in the websocket status serializer (REST already
did) so the card renders "Empty" instead of "?" where state is ambiguous.
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.
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.
Two adversarial-input fixtures added on the 0.2.4.6 branch were
missing the # nosec annotation that 32b3a93e established for the
same pattern. New TestNotArmedDiagnosticLogging test for the #1429
defensive diagnostic uses bind_address="0.0.0.0"; new
test_queue_start_user_attribution.py (#1670 fix) uses a /tmp path
in a PrintArchive fixture. Same annotation-only convention as the
test_virtual_printer.py sites.
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 hardcoded /tmp paths and 0.0.0.0 bind in
test_archives_api.py / test_attach_timelapse_safe_path.py /
test_vp_mqtt_bridge.py are deliberate adversarial-input fixtures for
the path-traversal containment tests and the #1429 bind_address=0.0.0.0
auto-resolve path — not insecure temp-file usage by the tests. Same
nosec-without-comment pattern as the existing test_virtual_printer.py
sites.
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.
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.
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.
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.