Commit graph

98 commits

Author SHA1 Message Date
maziggy
28a6ca6f4d Add CAP_NET_BIND_SERVICE everywhere the service is defined (#2549)
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.
2026-08-04 08:33:30 +02:00
maziggy
4f2c073a34 fix(vp): gate the slicer's AMS pick behind the toggle and scope its badges (#2700)
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.
2026-07-31 16:16:39 +02:00
Sergey Dontsov
13c37ffe51 fix(vp): scope saved AMS mapping to the printer it was resolved against
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.
2026-07-31 11:08:58 +03:00
Sergey Dontsov
bab1cfb906 feat(vp): per-VP "Save AMS mapping" toggle + reprint auto-apply
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
2026-07-31 11:08:58 +03:00
maziggy
d0efb9db9e fix(vp): relay A2L AMS filament to the slicer instead of blanking every slot
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.
2026-07-29 08:40:42 +02:00
maziggy
0f203ce7ca fix(scheduler): force color match now distinguishes PLA variants (#2650)
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.
2026-07-24 12:54:22 +02:00
maziggy
2e45893dd5 feat(print-options): add "Auto" state to bed levelling, flow & nozzle-offset calibration
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
2026-07-20 17:55:56 +02:00
maziggy
f2e113ce20 feat(vp): mirror live print progress to the slicer (#1887)
Some checks failed
Security Audit / Python Security Analysis (Bandit) (push) Failing after 2s
Security Audit / Container Security Scan (Trivy) (push) Failing after 1s
Security Audit / Backend Security Audit (push) Failing after 1s
Security Audit / Frontend Security Audit (push) Failing after 1s
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.
2026-07-11 10:06:03 +02:00
maziggy
7d4dfd5a7d fix(vp): stop uvloop from silently truncating VP FTP uploads (#1896)
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.
2026-07-05 10:32:13 +02:00
maziggy
ba6b1a8436 fix(vp): evict MQTT clients on drain timeout + tighten TCP keepalive (#1872)
Some checks failed
Security Audit / Python Security Analysis (Bandit) (push) Failing after 1m58s
Security Audit / Frontend Security Audit (push) Failing after 13m10s
Security Audit / Backend Security Audit (push) Failing after 13m15s
Security Audit / Container Security Scan (Trivy) (push) Failing after 13m19s
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.
2026-07-02 10:13:01 +02:00
maziggy
932aa557f2 fix(vp): route non-proxy camera passthrough by target model — 6000 for A1/P1 (#1868)
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.
2026-07-02 09:53:33 +02:00
maziggy
38b8a87c11 fix(vp): close #1780 race — bump slicer-MQTT wait to 5s + retroactive stamp
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.
2026-06-23 12:33:02 +02:00
maziggy
166e9f9ef2 fix(vp): correct #1780 root cause — VP intake key mismatch dropped every slicer field
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.
2026-06-21 14:09:05 +02:00
maziggy
d196cfc500 fix(virtual-printer): forward H2C rack-swap nozzle pick from slicer to dispatch (#1780)
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.
2026-06-19 13:19:31 +02:00
phieb
b414af6b1c
feat(gcode-injection): per-VP opt-in auto-print injection toggle (#1516) (#1656) 2026-06-19 11:29:07 +02:00
maziggy
2cf6f29503 fix(vp): Send All enqueues one item per plate; archive delete cascades to queue
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.
2026-06-13 15:58:57 +02:00
maziggy
8a63fcbf57 fix(vp): apply tray_exist_bits empty-slot cleanup to slicer-facing cache (#1726)
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.
2026-06-13 08:42:59 +02:00
maziggy
7190fc2d13 fix(logs): demote benign "not connected" + "may linger" warnings
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.
2026-06-12 15:13:40 +02:00
maziggy
9c4252911b fix(vp): overlay incoming dict-shaped push_status fields onto cache instead of replacing (#1622 round 5)
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.
2026-06-12 14:04:15 +02:00
maziggy
da799447f6 fix(vp): accumulate cached push_status per-field instead of allowlist (#1622)
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.
2026-06-11 17:23:49 +02:00
maziggy
5644f11495 debug(vp): env-flagged bridge-synthesised reply trace for slicer↔printer #1622 round-3 triage
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.
2026-06-11 15:09:24 +02:00
maziggy
19eed8eba0 debug(vp): env-flagged command-flow trace for slicer↔printer #1622 round-2 triage
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.
2026-06-11 13:35:20 +02:00
maziggy
4ab7339fe4 debug(vp): env-flagged wire-payload dump for slicer-mirror triage (#1622)
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.
2026-06-11 10:02:22 +02:00
maziggy
defec334c4 feat(a2l): add Bambu Lab A2L support (#1684)
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.
2026-06-10 10:59:27 +02:00
maziggy
7a9c78c32c ● fix(vp): stop enforcing MQTT keepalive 1.5x to match real Bambu firmware (#1548)
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.
2026-06-09 08:22:38 +02:00
maziggy
d597d36d5b fix(vp): slice FTP passive ports per VP, drop bridge-mode RAM by 95% (#1646)
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.
2026-06-07 08:39:40 +02:00
maziggy
e895d8350a fix(vp): re-fire FINISH after project_file ack so slicer releases (#1658)
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.
2026-06-06 08:51:09 +02:00
maziggy
aed01f875a fix(vp): resolve hostname/FQDN targets in MQTT bridge IP encoding (#1429 follow-up)
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.
2026-06-05 09:50:59 +02:00
maziggy
db1c664fce feat(vp): surface why MQTT bridge IP encoding didn't arm (#1429 defensive)
_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.
2026-06-04 11:24:20 +02:00
maziggy
9cc4b6aa60 fix(virtual-printer): #1610 add Bambu cipher pin to every slicer-facing TLS context
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.
2026-06-03 10:04:37 +02:00
maziggy
cdc27eb517 fix(virtual-printer): #1429 follow-up — auto-resolve VP IP when bind_address is 0.0.0.0
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.
2026-06-03 09:07:57 +02:00
maziggy
5d6d928b3f fix(virtual-printer): #1429 net.info[*].ip cache leak + mode wire-value rename
#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.
2026-06-02 14:33:20 +02:00
maziggy
396e9aa09e security: harden path-traversal class across routes + services; fifth CI backstop
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.
2026-06-02 12:12:54 +02:00
maziggy
4ffefa60f4 chore(vp): per-minute MQTT status-push counter for idle-disconnect triage (#1548 follow-up)
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.
2026-05-30 14:47:06 +02:00
maziggy
597762685c fix(virtual-printer): #1558 Send pre-flight + slicer-surface audit bundle
#1558: cached-as-base push_status only forced gcode_state=IDLE while letting
  the real printer's live-progress fields (mc_percent, stg_cur, layer_num, ...)
  leak through. Bambu Studio's Send pre-flight read them as busy and refused.
  The cached branch now overrides the activity-field set the same way it
  already overrode storage indicators (#1228) and protocol fields.

  Same bundle ships a multi-round VP audit that found adjacent bugs in the
  same family:

  - #1558: cached branch zeroes mc_print_stage / mc_percent / mc_remaining_time / stg / stg_cur / layer_num / total_layer_num / print_error
  - MQTT auth: per-IP rate-limit (5/60s lockout), hmac.compare_digest, access_code redacted in DEBUG log
  - FTP cmd_STOR streams chunks to disk + 4 GiB cap (was buffering whole upload)
  - Sticky-keys allowlist extended with upgrade_state / xcam / hw_switch_state / nozzle_diameter / nozzle_type / online / ams_status
  - _pending_files cleanup in finally for archive / queue / dispatch handlers
  - _add_to_print_queue position uses MAX+1 (was hardcoded 1)
  - DELETE VP removes orphan PendingUpload rows + upload_dir from disk
  - Per-VP cert regenerates on shared-CA rotation (real signature verification, not DN match)
  - DHCP target-IP refresh + queue_force_color_match toggle now restart proxy VPs
  - Per-slicer bridge-response routing (multi-slicer cross-leak fix via sequence_id map)
  - Child-service readiness barrier (FTP / MQTT / Bind / SSDP) — no false is_running before sockets bind
  - H2D Pro O1E / O2D model codes added (experimental, needs field confirmation)
  - FTP passive port range widened 50000-51000; docker-compose + wiki updated
  - VP refresh_loop crash now unbinds raw_message_handler; tailscale catches asyncio.TimeoutError; SlicerProxyManager lifecycle hardening
2026-05-30 13:34:10 +02:00
maziggy
b663605318 fix(virtual-printer): honour client-negotiated MQTT keepalive instead of hardcoded 60s (#1548)
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).
2026-05-28 09:13:44 +02:00
maziggy
6bc6a1d683 feat(virtual-printer): setup diagnostic + one-click slicer-certificate export
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.
2026-05-22 13:24:35 +02:00
maziggy
173edd9b7c ● fix(vp-queue): inherit slicer print options instead of always using defaults (#1403)
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`.
2026-05-18 09:38:53 +02:00
maziggy
1bb0d4856d fix(vp): deep-merge ams on bridge cache so P1S/A1 partial pushes don't nuke AMS (#1387)
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.
2026-05-17 09:45:29 +02:00
maziggy
072b8c8ce1 fix(vp): preserve AMS/vt_tray/net across incremental push_status updates (#1371)
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.
2026-05-16 09:21:19 +02:00
maziggy
0c92a4d326 fix(vp): broadcast archive_created so Archives page refreshes live (#1282)
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.
2026-05-12 13:36:32 +02:00
maziggy
59f7d736e3 Added BACKERS.md 2026-05-12 12:18:56 +02:00
maziggy
0d6171dc9a fix(vp): emit FINISH after FTP upload so Print-flow slicers unwedge (#1280)
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.
2026-05-12 10:19:34 +02:00
maziggy
ae43ced0ef fix(vp): honor workflow default print options in queue-mode receive (#1235)
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.
2026-05-12 08:59:14 +02:00
maziggy
ceffcfaef6 fix(vp): overlay storage indicators on cached push so slicer pre-flight passes for P1S/A1 targets (issue #1228)
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.
2026-05-08 09:19:09 +02:00
maziggy
64899a8ca4 refactor(virtual-printer): drop Tailscale LE cert path, keep toggle informational
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.
2026-05-03 14:58:29 +02:00
maziggy
7dea33d0d8 feat(vp): mirror live target printer state to slicer in non-proxy modes
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.
2026-05-03 13:43:54 +02:00
maziggy
459cfdc51f fix(virtual-printer): queue mode pins per-slot type+color so scheduler can match colour (#1188)
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.
2026-05-02 08:38:46 +02:00
maziggy
01a7e6ee93 fix(archive,vp): strip .gcode.3mf properly + sync review/archive name (#1152)
@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.
2026-05-01 12:34:56 +02:00
maziggy
c2e7f8eb4b feat(vp): add archive name source toggle (metadata/filename) (#1152)
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.
2026-04-29 06:54:08 +02:00