Commit Graph
293 Commits
Author SHA1 Message Date
maziggy d4ad41d850 fix(hms): action buttons actually reach the printer (#1830)
Three distinct bugs combined into one user-facing failure: clicking
Stop / Problem-solved-and-resume / Ignore-and-resume returned 200 OK
but the printer didn't act, modal stayed up, print stayed paused.
Verified by injecting candidate command shapes on device/<sn>/request
against a live H2D paused on a wrong-plate HMS (print_error=0x05008051).

(1) hms_resume / hms_stop dispatched the "err"-bearing shape that
BambuStudio doesn't actually send; Bambu firmware silently rejects it.
Both now send the plain shape ({"print":{"command":"<x>","param":"",
"sequence_id":"0"}}). PAUSE -> FAILED in 1.7s for stop, PAUSE -> RUNNING
in <2s for resume.

(2) IGNORE_RESUME mapped to idle_ignore, which is BambuStudio's
"dismiss a warning" command and only works for non-pause warnings.
hms_ignore now branches on state.state == "PAUSE": paused -> plain
resume; not-paused -> idle_ignore with the full-length err.

(3) 64-bit hms[]-array faults were truncated to a non-matching err.
short_code in _parse_status discarded 32 of the 64 identifier bits, so
the firmware didn't match it to the active fault. HMSError.full_code
now carries the canonical hex identifier (16 chars for hms[] faults,
8 chars for print_error faults). Catalog lookup tries 16-char first,
falls back to 8-char. HmsActionBody.print_error pattern relaxed to
^[0-9A-Fa-f]{8}([0-9A-Fa-f]{8})?$.

(4) execute_hms_action returned publish-success as success, masking
every silent-rejection bug above as 200 OK. Route now snapshots
(state.state, len(state.hms_errors)) before dispatch, awaits
HMS_ACTION_ACK_WAIT_SECONDS (default 2.5s, module-level so tests
override), and returns 502 with "Printer did not acknowledge HMS
action within 2.5s" if state didn't move.
2026-06-27 09:18:57 +02:00
Zelda 3ddf8d847e [Feature]: HMS Actions (#1743) 2026-06-26 14:40:25 +02:00
Ed 4c67d8a4e1 feat: Unify print dispatch through the scheduler (#1625) 2026-06-26 12:31:48 +02:00
maziggy c236fdc650 fix(auth): expose /api/v1/system/appliance through the auth middleware allowlist
The /system/appliance endpoint is fetched by the SPA's i18n bootstrap on
  mount to seed locale, hostname, timezone, and the chrony NTP-gate state
  BEFORE any login state exists. The route handler itself has no auth
  dependency and the test_route_auth_coverage allowlist correctly marks it
  public, but the global auth_middleware in main.py — which short-circuits
  every /api/ path not in PUBLIC_API_ROUTES — was never told about it.
  Result: every browser session on an auth-enabled install logged a 401
  on the appliance endpoint before login.

  Added /api/v1/system/appliance to PUBLIC_API_ROUTES with a comment
  pointing at the dual-list pattern so this doesn't drift again, and a
  regression test in TestAuthMiddlewarePublicRoutes that posts /auth/setup
  to turn auth on, then asserts the endpoint returns 200 with the
  documented shape (hostname / timezone / locale / time_synced fields all
  present).
2026-06-25 15:19:28 +02:00
maziggy fd61812d01 feat(drying): show active-cycle filament + target temperature on the AMS drying badge
Bambu's per-tick AMS push carries only the dry_time countdown — the
  filament name and target temperature the user chose are never echoed on
  the wire. The AMS card had no source of truth for them and rendered the
  bare "Drying · 11h 35m left". The badge now shows
  "Drying · PETG @ 65°C · 11h 35m left", matching the cycle the user
  actually started.

  BambuMQTTClient caches {ams_id: {filament, temp}} on send_drying_command
  (mode=1), clears on mode=0 and on the dry_time falling edge to 0 — the
  same per-AMS edge detector that drives the smart-plug-after-drying
  callback. PrinterManager.get_drying_targets exposes it, the four
  printer_state_to_dict call sites thread it through, AMS schema gains
  dry_target_temp + dry_filament, and routes/printers.py builds the same
  fields into the manually-constructed AMSUnit response.

  When no cached target exists (drying started in a previous backend
  lifetime, or initiated outside Bambuddy), the badge falls back to the
  first loaded tray's tray_type + RFID-recommended drying_temp — the
  heuristic the popover already uses to seed defaults.

  i18n: printers.drying.targetSummary = "{{filament}} @ {{temp}}°C" in
  all 11 locales. Parity check 5356 leaves per locale.

  Note: a user reported the H2D's own physical display still labels the
  cycle by the loaded tray's filament (e.g. "PLA" instead of the
  Bambuddy-requested "PETG"). The wire payload is correct end-to-end —
  journalctl shows filament: "PETG" sent and result: success ACKed — and
  the badge in Bambuddy's own UI now reflects what we actually sent,
  independent of the firmware's display choice.
2026-06-25 13:27:32 +02:00
maziggy 8d6f701f1d feat(drying): continue drying while printing + gate rotate-spool when tray loaded (issue #1816)
Continue Auto-Drying while a print is running on capable hardware.
  New Settings > Print Queue > "Continue drying while printing" toggle
  (default OFF). Extends _check_auto_drying in print_scheduler.py to
  evaluate running printers when supports_drying_while_printing(model,
  firmware) returns true. Strict allowlist verified per Bambu wiki
  release notes for "Print While Drying" / "printing while filament is
  drying": H2D 01.03.00.00+, H2C/H2S/P2S/H2D Pro 01.02.00.00+, X2D/A2L
  01.01.00.00+, X1C 01.11.02.00+. P1*, A1, A1 Mini, X1 (non-C), X1E
  intentionally excluded. Mid-print drying temperature is capped at
  max(40, preset_temp - 5) to protect spools from heat damage inside the
  hot enclosure during a print, matching Bambu's own "lower drying
  temperature during printing" guidance.

  Rotate-spool toggle in the drying popover is now disabled when any tray
  in the targeted AMS has filament threaded into the feed tube
  (tray.state === 11). The whole AMS rotates as one mechanism, so a
  single loaded slot locks the entire unit. Previously the toggle was
  always clickable and the firmware rejected with dry_sf_reason=[3]
  (ConsumableAtAmsOutlet) after the click. The first cut keyed on the
  printer-level tray_now but missed the H2D's typical post-print state
  where tray_now resets to 255 while filament stays in the tube — the
  per-tray state field reports it correctly. Submission also clamps
  rotateTray off so a stale-true state from a previous AMS can't leak
  through.

  Backend: supports_drying_while_printing in printer_manager.py covers
  display names and internal SSDP/MQTT codes (O1D, O1E/O2D, O1C/O1C2,
  O1S, N6, BL-P001, N7, N9). New print_drying_enabled boolean in
  settings schema. Frontend: toggle on SettingsPage, gate + clamp on
  PrintersPage drying popover using existing amsData cache. i18n: 3 new
  keys x 11 locales, no English fallback. Tests: 7 cases on the gate
  matrix (TestSupportsDryingWhilePrinting), 4 cases on the scheduler
  mid-print path (TestMidPrintDrying), 9 cases on the rotate gate state
  transitions. Full backend pytest -n 30 green (4251/4251), ruff clean,
  frontend npm run build clean, i18n parity 5355 leaves per locale.
2026-06-25 12:47:26 +02:00
maziggy 261c376d1f fix(spoolbuddy): close #1815 — preserve PFUS/PFCN setting_id in resolver
SpoolBuddy "Assign to AMS" with a Bambu Cloud user preset (PFUS) left
  Bambu Studio showing "Generic <Material>" instead of the user's
  custom preset. Root cause: the defensive filter that catches
  PFUS/PFCN leaks into tray_info_idx also cleared setting_id —
  but PFUS/PFCN are VALID setting_id values, just not valid
  tray_info_idx values. When the cloud detail lookup didn't return
  a filament_id (cloud unauth on the on_ams_change replay path,
  transient failure, or older custom presets), both fields got
  cleared and the caller's generic-material fallback overwrote
  setting_id with GFSG99 — slicer resolved to Generic PETG.

  Fix: the filter still clears tray_info_idx for PFUS/PFCN/material-
  name leaks, but preserves setting_id when it's a valid slicer
  reference (PFUS / PFCN / GFS). Material-name leaks still clear
  both. Post-fix MQTT carries tray_info_idx=GFG99 (firmware-acceptable
  for HMS/drying/colour) AND setting_id=PFUS<hash> (slicer uses this
  to load the actual user preset).

  What stays the same: Bambuddy's own AMS card still displays
  the generic material on cloud-unauth paths — same fundamental
  limitation as today. Fixing that needs a deeper layered fallback
  (LocalPreset name match, printer kprofile query, cloud-detail
  cache) and is out of scope for this drop. Slicer-side fix is
  the reporter's explicit ask.
2026-06-25 09:26:33 +02:00
maziggy 4b0150b0ec fix(mqtt): close #1822 — promote H2S tray_now to 254 on all-external prints
H2S firmware reports tray_now=0 (the AMS's idle slot) throughout
  external-spool prints instead of 254 like X1C/P1S/A1 do, so the
  single-nozzle branch's 0-3 passthrough landed state.tray_now on slot
  0 — UI highlighted AMS SLOT 1 instead of the external spool.
  Usage credit was unaffected (#1276 covers that via ams_mapping).

  The single-nozzle branch now checks _captured_ams_mapping (slicer-
  captured per-filament mapping that the request-topic intercept
  already tracks) before the existing P2S multi-AMS resolver. When
  every entry is -1, the print uses ONLY the external spool, so
  state.tray_now is promoted to 254.

  Narrow on purpose: AMS-only [5] and mixed [5, -1] are NOT
  overridden — we have no evidence H2S misreports mid-print swaps, and
  trusting the firmware preserves correctness for users with multi-
  filament setups. No-mapping prints (printer-screen start) fall
  through unchanged.
2026-06-25 09:11:23 +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
Stefano Maffeis 271560f7cb Fix camera port diagnostic for A1/P1 printers (#1799) 2026-06-22 14:42:54 +02:00
maziggy 7e6b390d74 feat(notifications): template-driven finish-photo email embed + user_print_* rename (#1792)
Reporter (email provider, "Reason: unknown" failures) wanted a camera snapshot
  in failure emails. The finish-photo capture path shipped in 0.2.5b1 (#1397)
  already loads JPEG bytes into archive_data["image_data"] for terminal print
  events, and pushover/telegram/discord/ntfy users have been getting them.
  Email was the one provider that dropped the bytes on the floor. Reporter
  separately flagged that the Message Templates list shows "Print Completed"
  and "User Print Completed" with no visual cue they're different dispatches.

  Both fixes in one commit because they touch the same UI surface (Message
  Templates) and both stem from the same reporter conversation.

  1) Inline finish-photo embed in email — template-driven, opt-in.

     _send_email now accepts finish_photo_url alongside image_data. Inline
     embed fires only when bytes are present AND URL is set AND the rendered
     body contains that URL — i.e. the user's template referenced the existing
     {finish_photo_url} variable. The multipart/related shape wraps a
     multipart/alternative (plain + HTML) plus an inline MIMEImage with
     Content-ID: <bambuddy-finish-photo>. The HTML part replaces the escaped
     URL in-place with the cid <img>, so the image appears WHERE the user put
     the variable in the template, not stapled to the bottom. Plain-text part
     keeps the URL as a clickable link for non-HTML clients.

     First draft of this fix unconditionally inlined the photo whenever
     image_data was present, which bypassed the template system. Reverted to
     the template-driven contract: default templates unchanged, opt-in by
     editing the template body to include {finish_photo_url}.

  2) user_print_* template name disambiguation.

     The four user_print_* templates are the per-user SMTP emails sent to the
     print's submitter (advanced-auth-only path). They shared the "Print
     Completed" / "Print Failed" / etc. short names with the broadcast
     provider templates, so the Message Templates list was indistinguishable.
     The EVENT_NAMES display map in routes/notification_templates.py already
     used the disambiguated "… Email" labels, but the seed wrote the short
     name to the DB.

     DEFAULT_TEMPLATES now seeds the four user_print_* rows with " Email"
     suffix so fresh installs are correctly labelled. New
     _migrate_rename_user_print_template_names runs on startup and updates
     existing rows where the name still matches the old default. Admin-edited
     names are preserved. Standard SQL UPDATE works on both SQLite and
     Postgres without dialect branching.
2026-06-22 11:15:39 +02:00
maziggy 9d74f9281b feat(deficit): backup-aware filament deficit check, colour-strict (#1762)
When the printer reports ams_filament_backup=True,
  compute_deficit_for_queue_item pools remaining_grams across spools
  matching (preset, colour) on the same printer (scoped per extruder on
  dual-nozzle) before declaring a per-slot shortfall. Identity is strict:
  same slicer_filament preset AND same colour (alpha-normalised). Two
  PETG HF spools in different colours are NOT pooled — the firmware would
  swap correctly but the print would change colour mid-run. Spoolman side
  mirrors the rule via filament.id + color_hex. Backup OFF falls back to
  the pre-PR per-slot accounting line-for-line.

  8 new test cases in TestFilamentDeficitBackupAware pin pool covers,
  pool insufficient, different presets, backup-OFF regression, dual-
  extruder side scoping, no-preset never pairs, colour-strict, and
  alpha-hex normalisation. The 8 pre-existing test_filament_deficit.py
  cases stay green.

  feat(printers): AMS Filament Backup modal with BS-style ring per pair

  Badge click on the Filaments section header (#1766) now opens a
  modal: filament-colour ring per backup pair, material name + rotation
  count in the centre, slot labels distributed around the colour band on
  contrast-aware pills. Closely modelled on Bambu Studio's Auto Refill
  widget. Lone slots are intentionally not listed. R / L badges per ring
  when the extruder map carries two distinct values; collapses to no-
  badge rendering for single-nozzle printers misflagged as dual.

  Esc keypress closes the modal. Theme-aware via CSS variables matching
  AMSHistoryModal. computeBackupGroups helper in utils/amsHelpers
  defensively dedupes duplicate ams.id entries observed on switch-VP
  aggregations.

  10 modal render cases pin: Esc closes / unmount nulls the listener /
  ring renders for pairs and omits lone slots / R-L badges only when
  extruder map has distinct values / empty state / toggle gating.
  13 frontend cases pin computeBackupGroups identity rules.

  feat(printers): active-print P-N pill on AMS slot tiles during RUNNING

  While the printer is mid-print, each AMS slot tile referenced by
  status.ams_mapping carries a small "P1 / P2 / P3" pill in the top-
  right corner, naming which print-slot is mapped to that AMS slot.
  Catches the #1762 comment-2 scenario: a queue job set for "any X1C"
  staged to a printer with mismatched filament, no way to verify mid-
  print. Same wire data (status.ams_mapping is already on the wire) —
  the addition is purely surface.

  The existing ring-bambu-green highlight for effectiveTrayNow keeps its
  meaning (currently extruding RIGHT NOW); the pill is the per-slot
  static assignment for the active print.

  chore(scheduler): log Print Anyway short-circuit at INFO

  _block_on_filament_deficit logs at INFO when it honours
  item.skip_filament_check, so a future "Print Anyway didn't work" report
  (third commenter on #1762 hit this shape) has actionable evidence in
  the standard support bundle without DEBUG. Bundled because the deficit
  fix makes the original symptom disappear for users with backup ON.
2026-06-22 10:00:02 +02:00
maziggy 4206d675eb feat(notifications): dedicate AI Failure Detection notification event (#1794)
Split Obico failure-detection dispatch out of the multiplexed
  on_printer_error event onto its own on_ai_failure_detection event so
  users can subscribe to AI alerts without also enabling HMS hardware-
  error pages, and so the discoverable label "AI Failure Detection" is
  what subscribes them rather than the unrelated "Printer Error" toggle.

  New column on notification_providers (default False, branched
  SQLite/Postgres migration), new notification_service.on_ai_failure_detection
  method, new ai_failure_detection template, obico_actions._notify swap.
  Frontend gets a summary badge, a toggle row with description, and ntfy
  priority surfacing. 14 new tests pin the routing + the regression guard
  ("Printer Error" alone must NOT receive AI notifications now). 11 locales
  covered.

  Existing providers keep working: HMS hardware errors continue to ride
  on_printer_error unchanged; users who want spaghetti alerts opt in via
  the new toggle.
2026-06-22 08:15:29 +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 a53dc20ca3 fix(usage-tracker): split mid-print AMS-Backup spool switch correctly (#1771)
Reporter forcefully started a print needing ~260 g with 180 g on the
  first spool and a backup spool in the AMS. Printer correctly consumed
  spool 1, AMS Backup switched, spool 2 finished the print. Bambuddy
  attributed all 260 g to spool 2 -- spool 1 untouched in inventory.

  Two stacking bugs produced the exact "all to second spool" symptom for
  prints without per-layer 3MF gcode data:

  1. bambu_mqtt.py:2135 wrote state.total_layers = int(data["total_layer_num"])
     unconditionally. P1S firmware pushes total_layer_num=0 at print end
     (same reset pattern other models do for layer_num / progress). The
     unconditional write clobbered the slicer's actual total to 0 before
     the usage tracker read it.

  2. usage_tracker.py:1129-1137 linear-fallback dumped EVERYTHING onto the
     last segment when total_layers was 0:
       if total_layers > 0:
           segment_grams = total_weight * (seg_end_layer - seg_start_layer) / total_layers
       else:
           segment_grams = 0.0   # <- entire print weight ends up on last segment

     Path 2 (AMS remain% delta) couldn't recover because (a) the emptied
     spool reported remain=-1 and (b) Bug-A had already added the second
     spool's key to handled_trays, suppressing the Path 2 lookup.

  Fix:

  - bambu_mqtt.py: only overwrite state.total_layers when the incoming
    value is positive (mirror of the existing _last_valid_layer_num
    pattern at line 2127). Explicit reset on new print start at
    _handle_print_start so the previous print's total can't bleed in.

  - usage_tracker.py: cascade the linear-fallback denominator -
    state.total_layers, then last_layer_num (already threaded in for
    the last_progress fallback), then equal-split as a bounded fence.
    Equal-split is still wrong but never dumps the whole print on the
    last segment, which was strictly worse.
2026-06-20 12:26:31 +02:00
maziggy b1cb26f6ee feat(ams-backup): add status badge + toggle, fix prefer-lowest (#1766)
Two tightly-coupled deliverables in one drop -- a new AMS Filament Backup
  status/control surface, and the #1766 fix that depends on it.

  Added -- AMS Filament Backup status + control
  - Parse bit 18 of top-level print.cfg into PrinterState.ams_filament_backup
    on every push_status. Verified against OrcaSlicer source
    (DeviceManager.cpp:4961) and a live H2D ON/OFF capture. Tri-state
    (None = A1 family / pre-cfg push) preserves today's behaviour.
  - Hold-timer guard (3 s) prevents stale frames from flickering the badge
    back to the printer's old cfg after a user-initiated toggle.
  - POST /printers/{id}/ams-backup toggle, set_ams_filament_backup() client
    method calling _set_print_option("auto_switch_filament", enabled).
  - GET /printers/{id}/inventory-remain endpoint exposes the same map the
    dispatcher uses (internal and Spoolman modes both work uniformly).
  - Small icon badge in the printer card's "Filaments" section header
    (placement reads as printer-wide because the cfg bit is printer-wide,
    not per-AMS). Click to toggle, success toast.
  - 5 i18n keys x 11 locales for the badge UI.

  Fixed -- #1766: prefer_lowest didn't pick lowest, ignored backup state
  - Backend gate in _compute_ams_mapping_for_printer: coerce prefer_lowest
    to False when status.ams_filament_backup is False; log the skip.
  - New effectivePreferLowest(setting, backup) helper applied at every
    frontend sort entry point: single-printer PrintModal, multi-printer
    hook per-printer, PrinterSelector InlineMappingEditor, FilamentMapping
    standalone editor (the last had NO preferLowest awareness at all
    before this change).
  - New preferLowestSortKey(f, inventoryByTrayId) mirrors backend's two-tier
    key exactly, including the banding tie-break (regular AMS < AMS-HT <
    external) so the client-side pre-compute matches the dispatch-time pick.
    An earlier draft used a flat `amsId * 4 + trayId` priority which gave
    external slots (ams_id = -1) a NEGATIVE priority -- caught in code
    review before commit.
  - Settings -> Filament -> "Prefer lowest remaining filament" gets an
    explanatory note about the printer-side AMS Backup dependency, with
    i18n key in all 11 locales.
2026-06-20 12:07:20 +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 3ef5119b7d fix(archives): render plate thumbnails server-side when sidecar slice skips them (#1759)
Bambuddy's archive cards were blank for every print sliced through the
  BS or Orca docker sidecars. The "Some recent prints couldn't be archived
  with thumbnails" banner pointed at install step 4 which is unrelated —
  that flag only fires on FTP-fetch failures, not on missing-thumb in the
  sliced 3MF.

  Root cause is upstream of Bambuddy: neither slicer CLI renders
  Metadata/plate_N.png when invoked headlessly with --slice --export-3mf.
  That render is a separate code path triggered by --export-png, which is
  mutually exclusive with --export-3mf and additionally needs a working
  display backend (BS 02.07.x's bundled GLFW is hard-locked to Wayland —
  even XDG_SESSION_TYPE=x11 + GDK_BACKEND=x11 + QT_QPA_PLATFORM=xcb don't
  switch it back). An Xvfb display in the sidecar wouldn't help even if we
  wired the second-pass call. The Orca sidecar has been silently shipping
  thumbnail-less 3MFs from STL inputs since launch; nobody noticed.

  Fill the gap on the Bambuddy side: new plate_thumbnail.py renders the
  missing thumbnails after the slice returns. inject_plate_thumbnails_if_missing
  parses the sliced zip, finds every Metadata/plate_N.gcode entry that
  doesn't have a matching plate_N.png, loads 3D/3dmodel.model via trimesh,
  renders an isometric Bambu-green-on-dark view at 512x512 + 128x128 via
  the same matplotlib Agg pipeline as stl_thumbnail.py, and re-packs the
  zip with the PNGs injected. Visual style matches Bambuddy's existing
  library thumbnails — archive cards stay consistent inside Bambuddy rather
  than chasing parity with desktop Studio's plate render. Best-effort:
  input bytes are returned unchanged on any failure so the slice flow itself
  can never fail because of a missing thumbnail. Idempotent: re-running on
  an already-injected 3MF returns the input verbatim.

  Wired into both library.py slice paths via result._replace; covers the
  cross-class merged-multi-plate path automatically (merged bytes flow into
  the same write site). No sidecar Dockerfile change required — an earlier
  attempt to install Xvfb in Dockerfile.bambu-studio was a false start and
  is not part of this drop.

  Dependencies: trimesh's 3MF loader uses networkx (scene-graph traversal)
  and lxml (model.xml parse) lazily inside the 3MF code path — both added
  to requirements.txt because they aren't strict trimesh transitives.
2026-06-19 07:28:09 +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 be7e85344c fix(finish-photo): drive capture from stg_cur=22, drop dispatch force-on (#1721)
capture_finish_photo (default-on) was forcing the timelapse MQTT field to
  true on every print, even when the user explicitly unchecked Timelapse in
  the slicer send dialog. On profiles with Timelapse Type = Smooth, that
  flipped the printer's timelapse_record_flag and un-gated the per-layer
  M622 J1 wipe blocks the slicer had baked in — toolhead parked off the
  part every layer, on prints the user opted out of recording.

  Root cause: #1397 implemented the finish-photo feature as a side channel
  of "force the printer into timelapse-recording mode at dispatch" so the
  last-frame extractor had a video to pull from. That conflated recording a
  timelapse with snapping a finish photo, and the per-layer side effects
  were decided at slice time by the user's timelapse_type, which Bambuddy
  has no visibility into post-slice.

  Fix: replace the force-on with a clean MQTT-state-driven trigger.

    bambu_mqtt.py fires a new on_finish_photo_moment callback when
    stg_cur transitions INTO 22 ("Filament unloading") while
    _was_running AND end-of-print gate matches (progress >= 99 OR
    layer_num >= total_layers OR remaining_time <= 0). The gate
    disambiguates from mid-print color swaps (which also transit
    stage 22 but at progress < 99). FINISH-state fallback in the same
    handler fires the callback at the existing transition if stage 22
    never arrived (cancel, external-spool-only, HMS halt, firmware
    variants).

    main.py registers on_finish_photo_moment as a top-level handler.
    It pre-captures one camera frame at the trigger edge (external cam
    → buffered RTSP → fresh RTSP via capture_camera_frame_bytes) and
    caches the JPEG bytes in _stage22_finish_frames[printer_id].
    _background_finish_photo consumes the cached bytes before its
    existing live-grab chain, so the saved photo has the better
    framing (toolhead parked, before bed drop) without restructuring
    the archive-resolution / fallback / notification wiring.

    When a timelapse IS actively recording (user explicitly opted in),
    pre-capture is skipped — _capture_finish_photo_from_timelapse
    still extracts the last frame, which is still the best framing
    and now has no force-on side effects because the user wanted the
    video.

  Removed: resolve_effective_timelapse, _resolve_effective_timelapse
  wrapper, both background_dispatch call sites, the print_scheduler call
  site, the archive.bambuddy_forced_timelapse write, _cleanup_forced_timelapse
  (~75 lines including the FTP-DELE walk across /timelapse, /timelapse/video,
  /record, /recording) and its call site. All paths now read
  bool(item.timelapse) / bool(job.options.get("timelapse", False)) directly.
  The archive.bambuddy_forced_timelapse DB column stays defined (default
  False) for back-compat with existing rows — no consumer reads it anymore.
2026-06-13 13:28:58 +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 0de71ca412 fix(printer): "off" flow_cali / nozzle_offset_cali now actually suppress the stage
The Re-print and Schedule modal toggles for Flow Calibration and Nozzle
  Offset Calibration accepted "off" correctly and flowed it through to the
  project_file MQTT publish — Bambuddy sent extrude_cali_flag: 2 and
  nozzle_offset_cali: 2 per the "1 = run, 2 = skip" reading inherited from
  the #1478 / #1682 work. Live test on H2D 01.x: with both toggles off,
  the stg queue still scheduled stage 8 ("Calibrating dynamic flow") and
  stage 39 ("Nozzle offset calibration"), and the printer ran both at
  print start.

  Root cause: 2 means "skip the explicit pass but still apply / verify
  stored PA via the calibration stage" — close to a no-op K-factor wise
  but the per-print physical sequence still runs. 0 is the encoding that
  actually drops the stage from stg. A BambuStudio Send-dialog capture
  on the same firmware showed 0 for both fields when the user unchecked
  the calibrations — contradicting the #1478 commit's read of "BambuStudio
  never sends 0."

  Fix:
  - extrude_cali_flag = 1 if flow_cali else 0  (was: else 2)
  - nozzle_offset_cali = 1 if (nozzle_offset_cali and is_dual_nozzle) else 0
    (was: else 2)

  Dual-nozzle gate stays; single-nozzle prints continue to force-skip the
  nozzle-offset cali their head doesn't support (#1682). The 1 (run)
  branch is unchanged.

  Verified live on the same H2D after the patch: stg dropped to
  [29, 13, 4, 14, 3] (cooling, homing, filament change, nozzle cleaning,
  vibration comp). Stages 8 and 39 gone.

  Vibration compensation is NOT fixed by this commit: vibration_cali is a
  bool in our and BambuStudio's wire format, and the H2D firmware queues
  stage 3 regardless of the false value. Firmware-side, not solvable at
  the dispatch layer with the current field. Filed as a follow-up.
2026-06-12 16:33:56 +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 1c42a9f1fd remove(slicer): drop bundle import; fix cloud preset type/from for CLI (#1712)
Bundle import never delivered what it implied: BambuStudio's .bbscfg export
  strips system processes/filaments, so importing a bundle left users without
  process presets and slicing fell back to embedded settings on STL. Bundle
  mode also hid the standard tier behind a constrained dropdown, the actual
  trap reported here.

  Removed end-to-end:
  - backend: POST/GET/DELETE /slicer/bundles*, SliceRequest.bundle,
    SliceBundleSpec, dispatch fork in library.py, bundle-context params on
    the filament-requirements endpoints, bundle-fingerprint cache key in
    slice_preview.py, SlicerApiService.{import,list,get,delete}_bundle and
    slice_with_bundle, BundleSummary / BundleNotFoundError.
  - frontend: BundlePicker + BundleStringDropdown, isBundleMode + every
    branch, bundle state/queries/dispatch in SliceModal.tsx, SlicerBundle /
    SliceBundleSpec types, three bundle API methods. buildCompatibilityIndex
    loses its bundle path; presetCompatibility keeps compatible_printers
    plus the @BBL fallback.
  - SlicerBundlesPanel turns into a permanent static notice explaining the
    removal, alternative import paths, and the new slice-time lookup order
    (Imported > Orca Cloud > Bambu Cloud > Standard sidecar fallback).
  - i18n: slicerBundlesRemoved.{title,description,alternatives,lookupOrder}
    translated across all 11 locales; slice.bundle*, slicerBundles.* keys
    removed.

  Fixed (surfaced by removing bundle mode):
  - _resolve_cloud and _resolve_orca_cloud now force type per slot and pin
    from: "system" on the payload before json.dumps. Bambu Cloud ships
    type as "printer"/"print" and routinely empty `from`; the BS CLI's
    --load-settings parser rejects both with return -5 / "input preset
    file invalid". Standard tier already did this; cloud paths now match.
2026-06-12 10:14:06 +02:00
maziggy e737c84c6e fix(diagnostic): skip external_storage check on A1 / A1 Mini (#1703)
A1 and A1 Mini ship without a MicroSD slot at all - there is no
  firmware-side "Store sent files on external storage" toggle and the
  slicers don't surface a slicer-side equivalent either. The connection
  diagnostic was reading state.store_to_sdcard (home_flag bit 11), which
  is never set on these models, so the check fell through to fail for
  every A1-series user. Combined with the absent slicer UI it left users
  thinking Bambuddy was wrong about a setting their hardware does not
  have.

  New NO_EXTERNAL_STORAGE_MODELS frozenset in utils/printer_models.py
  enumerates A1, A1 Mini, and their internal codes (N1, N2S, A04, A11,
  A12). has_external_storage() returns False for those, True for
  everything else. Unknown models default to True so the check stays
  active for future Bambu lineup additions - new no-slot models must be
  added to the set explicitly.

  The diagnostic now short-circuits to skip before reading
  store_to_sdcard when printer.model is in the set. X1, P1, P2S, H2,
  and X2D are unchanged - the bit-off -> fail signal is still the right
  read for them.

  The companion FTP-upload-timeout symptom in the same bug report (ftp
  code 28 from BambuStudio when sending to the proxy VP) is a separate
  Docker-bridge-mode networking constraint, not addressed here.
2026-06-10 08:54:24 +02:00
maziggy 0793022818 fix(ams): keep slot card preset name in sync after spool swaps
The slot card on PrintersPage shows slot_preset_mappings.preset_name
  first in its display fallback chain. Three write paths swap which
  spool occupies a given slot:

  - internal manual assign (inventory.apply_spool_to_slot_via_mqtt)
  - internal RFID auto-assign (spool_tag_matcher.auto_assign_spool)
  - Spoolman RFID sync (main.auto_sync_spoolman_ams_trays)

  Only the first one was reconciling the row. After an RFID-driven
  spool change, the card kept surfacing the previous spool's preset
  name until the user opened Configure Slot manually.

  Reporter saw H2D-1 / AMS-B3 displaying "Bambu PLA Silk+" for a
  freshly-inserted Bambu PLA-CF spool. The matching row in
  slot_preset_mappings was last written in March when a PLA Silk+
  spool had been in that slot - confirmed live in the database.

  New backend/app/services/slot_preset_writer.py exposes a primitive
  upsert_slot_preset plus two derivation wrappers: one for the
  internal Spool ORM object, one for the Spoolman API dict shape.
  All three call sites now go through the helper, so the row stays
  in lockstep with the assigned spool regardless of inventory mode.

  Bug shape exists in both inventory modes and the patch fixes both
  per feedback_inventory_modes_parity. The Spoolman path was latent
  for users who'd never manually picked a slot preset; the same
  "stale row overrides correct catalog name" symptom appeared for
  those who had.

  Existing stale rows self-heal on the next RFID-driven swap.
2026-06-10 08:39:21 +02:00
maziggy e01d3a7979 feat(mqtt): one-shot device identification probe for unknown models
Logs device.dev_model_name / dev_product_name / dev_id / project_name
  at INFO level once per client session, falling back to device.keys()
  if none of the known fields are present.

  The MQTT push_status carries the model code in device.dev_model_name
  on every message, but nothing in bambu_mqtt.py reads or logs that
  field — so adding a new printer model meant chasing the code through
  either Bambu cloud or a manual mosquitto_sub. A2L (#1684) was the
  case that surfaced this: get_version also failed because the firmware
  disconnected right after request topic subscription, so the support
  bundle had no way to disclose the model.

  INFO level so the line lands in support bundles without enabling
  debug. One-shot via _device_id_logged, mirroring the existing
  _nozzle_fields_logged flag at line 2095, so push_status spam is
  avoided.

  Future-proofs against Bambu renaming the field (the fallback dumps
  device.keys() so a rename like model_name without the dev_ prefix
  is still observable). 3 unit tests in TestDeviceIdentificationProbe
  pin all three branches.
2026-06-10 07:38:02 +02:00
maziggy 60e31634b8 feat(diagnostic): add "Store sent files on external storage" check (install step 4)
Detects the printer-side variant of install step 4 — many users (esp. on
  clean installs) forget to enable this and only notice when their archive
  cards have no thumbnails. The diagnostic now catches it upfront.

  Detection: read state.store_to_sdcard, which Bambuddy already parses from
  MQTT push_status home_flag bit 11 (bambu_mqtt.py:153). Instant, no I/O.

  An FTP upload-and-verify probe was tried first and rejected. /cache is
  always writable from Bambuddy regardless of the slicer setting — only
  BambuStudio's own behaviour changes when the toggle flips, not the
  printer's acceptance policy. Confirmed empirically against X1C + H2D
  with the slicer option toggled off: probe succeeded, home_flag bit 11
  stayed True. So the only reliable signal is what the printer actually
  reports about its own state.

  Limitation: the printer-side variant only exists on newer firmware
  (P2S 01.02 / Bambu Studio 2.6+). On older versions the toggle lives
  only in the slicer and the printer never hears about it, so this check
  will pass even when the user is missing step 4 in BambuStudio. The
  skip-text and the wiki call this out explicitly. A reactive banner on
  the no-3MF archive-fallback path is planned as a follow-up to cover
  that case.

  Statuses:
  - pass:  state.store_to_sdcard is True
  - fail:  state.store_to_sdcard is False (-> overall escalates to problems)
  - skip:  no live state, disconnected, or field never populated
2026-06-09 12:08:37 +02:00
maziggy 72044e3a53 fix(usage): scope 3MF filament tracking to dispatched plate (#1697)
When a print targets a single plate from a multi-plate 3MF, both the
  internal Filament Inventory tracker and the Spoolman-mode tracker parsed
  the 3MF without a plate filter and summed every plate's filament — so a
  single lid print debited the spool the entire file's grey + black totals.

  The 3MF parser already supports plate_id (queue pre-flight uses it at
  print_queue.py:254/:286). Plumbed it through both dispatch paths:

  Queue path:
  - PrintSession gains a plate_id field; on_print_start queries the
    printer's currently-printing queue row and records queue_item.plate_id
    onto the session.
  - _track_from_3mf accepts plate_id and passes it to the extractor.
  - store_print_data moves its existing queue-item lookup above the
    extract and uses queue_item.plate_id as the plate filter.

  Direct-Print path (reprintArchive / printLibraryFile — never goes
  through the queue):
  - _print_plate_ids dict added in main.py, parallel to _print_ams_mappings.
  - register_expected_print accepts plate_id and stores it; the 2 sites in
    background_dispatch.py and the 1 site in print_scheduler.py now pass
    it (resolve was already happening, just needed reordering before the
    register call so the value is available).
  - Expected-print promotion in main.py injects _print_plate_ids[archive_id]
    into the session, guarded so a queue capture wins over the dict.
  - _get_start_plate_id helper feeds plate_id into all 3
    _store_spoolman_print_data call sites; spoolman_tracking.store_print_data
    takes the caller value first, falls back to queue_item.plate_id.

  PrintArchive.filament_used_grams stays file-level summed by design
  (#1593's contract — the archive describes the file, not the run); only
  the per-run usage attribution becomes plate-aware. Single-plate direct
  prints resolve to plate_id=1 → plate 1 = whole file, identical to the
  prior no-filter behaviour.
2026-06-09 09:16:36 +02:00
maziggy 2c2725cb53 fix(print): expose nozzle_offset_cali toggle for dual-nozzle printers (#1682)
Bambuddy's project_file MQTT payload hardcoded "nozzle_offset_cali": 2 (skip),
  giving users on H2D / H2D Pro / H2C / X2D no way to control the same toggle
  BambuStudio exposes. Critical for diamond-nozzle setups that must keep the
  calibration off.

  start_print() now takes a nozzle_offset_cali kwarg; the value is encoded as
  1 (run) or 2 (skip) and gated on is_dual_nozzle so single-nozzle machines
  always send 2 even if a stale flag arrives. The kwarg threads through
  printer_manager, both background_dispatch sites, and print_scheduler so
  every dispatch path respects the per-item setting.

  print_queue gains a nozzle_offset_cali column (DEFAULT TRUE, is_sqlite()
  branch for Postgres BOOLEAN). Settings default key default_nozzle_offset_cali
  defaults to TRUE to match BambuStudio. Schemas updated across print_queue,
  library FilePrintRequest, archive ReprintRequest, settings.

  PrintModal renders the new toggle only when the selected printer is dual-
  nozzle (printer-mode: nozzle_count===2; model-mode: DUAL_NOZZLE_MODELS).
  SettingsPage default-print-options row + QueuePage bulk-edit tri-state both
  hide unless any registered printer is dual-nozzle. Labels reuse the existing
  settings.default* keys so the only new i18n strings are
  settings.defaultNozzleOffsetCali / Desc and queue.bulkEdit.nozzleOffsetCali
  - real translations in all 11 locales.
2026-06-08 09:20:19 +02:00
maziggy 96ce403554 fix(archive): HTML-unescape 3MF Title metadata; correct VP name tooltip (#1658)
ThreeMFParser._parse_3dmodel left XML-escaped values raw, so a Title of
  "PCB Vise & Solder Station" landed in the DB as the literal "&amp;" and
  React re-escaped it on render to "&amp;amp;". Apply the same
  loop-until-stable html.unescape() the sibling ProjectPageParser already
  uses, uniformly across all <metadata> values.

  Same drop: rewrite the VP archive-name-source tooltip in all 11 locales.
  BambuStudio 2.7.x (PrintJob.cpp:314-325) overwrites the user-typed
  Send-dialog name with the slugified 3MF Title field whenever one is
  present, so the previous "handy if you renamed the job in the send dialog"
  copy was false. New text spells out the actual behavior; both Filename
  and Metadata modes often produce the same string for that reason.
2026-06-07 11:43:19 +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 12d17bfbe7 fix(photo): source finish photo from forced timelapse + cleanup (#1397)
Bambu's end-gcode lowers the bed at gcode_state=FINISH. Bambuddy's
  live-camera grab captured the bed already dropped, ruining the photo
  framing. Source the photo from a brief Bambu timelapse instead —
  firmware stops timelapse recording AFTER toolhead parks but BEFORE
  bed-drop runs, so the last frame frames the finished print correctly.

  When capture_finish_photo is on AND the user did not opt in to
  timelapse for this print, force timelapse=True at dispatch + mark the
  new PrintArchive.bambuddy_forced_timelapse column. After extraction
  (success or failure), cleanup deletes the locally-attached file,
  clears archive.timelapse_path, and walks the four scanner directories
  (/timelapse, /timelapse/video, /record, /recording) trying FTP DELE
  against the original filename. User-opted-in timelapses pass through
  unchanged.

  Resolver lives at services/background_dispatch.py::resolve_effective_timelapse
  (module-level so the print queue can reuse it). Both dispatch paths
  wired: background_dispatch.py (Print Now / Reprint) AND
  print_scheduler.py:_start_print (the queue). Field testing caught the
  scheduler gap on the first round — AST regression test now asserts
  start_print(timelapse=...) references effective_timelapse, not the raw
  item.timelapse, so a future refactor can't silently drop it.

  Extractor: ffmpeg -i input.mp4 -update 1 -q:v 2 out.jpg. Decoded
  frames overwrite the same output file, so the file left on disk is the
  literal last frame regardless of duration. Bambu records one frame per
  layer-change, so a 16-layer cube produces a 0.6 s timelapse — the
  original -sseof -1.0 approach seeked before the start of the file and
  returned frame 0 (empty bed). Decoding every frame is fine; Bambu
  timelapses are short by construction even on hours-long prints.

  Migration adds bambuddy_forced_timelapse branched on is_sqlite()
  (DEFAULT 0 / DEFAULT FALSE — PG rejects DEFAULT 0 for BOOLEAN).
  Verified live on postgres:16-alpine.

  Photo-task wait_for budget extends 45s -> 75s when timelapse_was_active
  so the notification carries the bed-up photo instead of falling back
  to the live-cam grab on slow links.

  Scope limit, documented in the camera wiki: prints started directly
  on the printer touchscreen / Bambu Handy / Bambu Studio Send bypass
  both dispatch paths, so the override doesn't fire there. Future
  option: mid-print M981 S1 P20000 MQTT toggle in on_print_start.

  Setting description rewritten in all 11 locales to drop the "only
  works when timelapse enabled" caveat (Bambuddy now forces it) and
  explain the kept-or-deleted behaviour.
2026-06-05 13:53:26 +02:00
maziggy e802bfc806 fix(ftp): cap TLS to v1.2 for X2D FTPS to dodge WRONG_VERSION_NUMBER on firmware 01.01.00.00 (#1638)
Reporter @vasmarfas saw X2D archive cards land almost empty - only print
  time, no filament weight / layers / MakerWorld link / thumbnail - and
  Spoolman filament-usage tracking went silent on the same printer.

  Support bundle traces the end-to-end: at print start
  backend/app/main.py::on_print_start tries the usual FTP-download dance
  for the 3MF, every implicit-FTPS connect attempt to the X2D fails with
  `[SSL: WRONG_VERSION_NUMBER] wrong version number (_ssl.c:1032)`, ~2
  minutes later "Could not find 3MF file for print" -> "Created fallback
  archive". Fallback path writes file_path="", file_size=0,
  content_hash=NULL, no layers / filament / model-link fields. Spoolman
  tracking degrades from the same root cause - both depend on the 3MF
  metadata parser.

  Proximate cause: Python 3.13's default ssl.create_default_context()
  negotiates TLS 1.3, the X2D's implicit-FTPS server on port 990 rejects
  the ClientHello. Same family as the P2S 01.02.00.00 bug from #1401
  (post-Python-3.13 TLS-1.3 breakage), different wire-level failure mode
  (P2S completes the handshake and truncates with 426; X2D fails the
  handshake outright).

  Same fix shape: add X2D to backend/app/services/ftp_profiles.py with
  cap_tls_v1_2=True, plus N6 -> X2D SSDP alias. Every other model stays
  on negotiated TLS 1.3.

  Honest caveat: hypothesis-driven trial, not a confirmed root-cause fix.
  WRONG_VERSION_NUMBER could equally describe the X2D switching to
  explicit FTPS (AUTH TLS on plaintext greeting) or moving FTPS to a
  different port - either would need a different code path. Reporter has
  been asked to test this build; if the cap doesn't clear it the registry
  slot stays useful and the next diagnostic round goes to openssl
  s_client from a network-adjacent host.
2026-06-05 08:30:19 +02:00
maziggy 18d534c945 feat(orca-cloud): integrate Orca Cloud profile sync across UI, slicer and SpoolBuddy
Reads, lists, and slices with profiles from a user's Orca Cloud account
  (OrcaSlicer 2.4.0-alpha's Supabase-backed sync) alongside the existing
  Bambu Cloud integration. Four sign-in providers (Google / Apple / GitHub /
  email+password); password defaults. Paste-flow PKCE because Orca's
  Supabase project only allowlists localhost redirect_to — open feature
  request at OrcaSlicer/OrcaSlicer#14028.

  Surfaces:
  - Profiles tab: new "Orca Cloud" tab next to "Bambu Cloud" with the same
    rich layout (search + 5 filter dropdowns + 3-column grouped grid +
    read-only detail modal)
  - SliceModal: 4-tier preset picker (orca_cloud > local > bambu cloud >
    standard); separate status banner per cloud; metadata-aware pre-pick
    scores Orca filaments above local (Orca's sync_pull returns full
    content inline so filament_type / filament_colour come for free, no
    per-setting fetch rate-limit dance)
  - ConfigureAmsSlotModal: orca_cloud as a new preset source (prefixed
    orca_<UUID> to match local_/builtin_); generic Bambu filament-ID
    derivation from parsed material (printer firmware can't grok Orca
    UUIDs); slot mapping persists preset_source='orca_cloud'
  - SpoolForm / SpoolBuddyWriteTagPage: Orca filaments merge into the
    cloud preset list via Promise.allSettled (OrcaProfileMeta is
    structurally identical to SlicerSetting)

  Backend:
  - services/orca_cloud.py: OrcaCloudService with PKCE / token exchange /
    single-use refresh rotation / get_user_info / list_profiles via the
    bare /sync/pull bootstrap path
  - routes/orca_cloud.py: 7 endpoints (auth/start, auth/finish,
    auth/password, status, logout, profiles, profiles/{id}); router-level
    _cloud_api_key_gate + per-route cloud_caller() so API-keyed callers
    (SpoolBuddy kiosk) properly resolve their owner User; just-in-time
    refresh with atomic persist-before-API-call
  - routes/slicer_presets.py: _fetch_orca_cloud_presets mirrors the Bambu
    Cloud fetcher (status vocabulary, 5min cache, permission shortcut);
    _dedupe_by_name extended to 4 tiers; UnifiedPresetsResponse gains
    orca_cloud + orca_cloud_status
  - services/preset_resolver.py: PresetRef.source extended with
    "orca_cloud"; _resolve_orca_cloud walks list + filters
  - 8 columns on users table for tokens (5 persistent) + transient PKCE
    handshake state with 10-min TTL (3); dialect-branched DATETIME /
    TIMESTAMP; auth-disabled mode falls back to Settings table
  - orca_cloud:auth permission folded into can_access_cloud API-key scope
    (same trust dimension)
2026-06-04 15:50:44 +02:00
maziggy 51abc4b7a8 feat(drying): enable AMS drying for H2C at firmware 01.02.00.00+ (issue #1624)
H2C was in _DRYING_UNSUPPORTED_MODELS. Move to _DRYING_MIN_FIRMWARE
  with the same 01.02.00.00 floor as H2S / P2S. Both SSDP model codes
  the H2C advertises (O1C single-nozzle, O1C2 dual-nozzle) get the
  same gate so supports_drying() fires correctly regardless of which
  form is stored on the printer record.
2026-06-04 10:57:46 +02:00
maziggy c571ad86dd feat(diagnostic): printer_publishing check + countdown UI (#1622)
The existing connection diagnostic proved TCP + TLS + auth + SUBSCRIBE but
  not that the printer was actually publishing reports. A wrong-cased serial
  passes mqtt_auth because the broker accepts the subscription regardless;
  the user-visible symptom is empty AMS / no K-profiles / no custom filaments
  in the slicer Device tab because the VP cached state is empty. Bambuddy
  already logged the actionable hint at bambu_mqtt.py:498 but only to
  container logs.

  New printer_publishing check turns that warning into a structured
  diagnostic result. Pass = bridge has seen at least one report since the
  last (re)connect; fail = zero reports across the wait window with fix-text
  pointing at the case-sensitive serial. Bounded 10s poll on the on-demand
  UI route, no wait on the support-package gathering path so bundling stays
  fast. Exits the moment a message arrives — typical wall-clock is 1-2s.

  Frontend renders an elapsed-seconds counter plus a "Listening for status
  report — up to 10s" hint during the pending state so the wait doesn't look
  hung. PUBLISH_WAIT_DEFAULT_SECONDS pinned on both sides.

  report_messages_since_connect exposed as a public property on
  BambuMQTTClient so the diagnostic doesn't reach into private state.
2026-06-04 10:54:04 +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 1e08c25a9f fix(stats): #1593 multi-plate parser + per-run project rollup + carry-over system totals + accuracy band
Two stacked causes under-reported multi-plate prints in the project
  rollup and the archive card.

  Root cause 1 - parser only read plate 1.

  ThreeMFParser._parse_slice_info used root.find(".//plate") and pulled
  prediction / weight from that one element. Any multi-plate file's
  archive-level print_time_seconds / filament_used_grams reflected
  plate 1 alone. The /plates endpoint already looped findall and was
  correct, which is why the plate carousel showed the right numbers
  while the archive card was wrong.

  Fix: loop every <plate> and sum prediction + weight. Per-plate
  concepts (plate_number, _plate_index, printable_objects) only set
  when there's exactly one plate - for multi-plate exports the
  archive represents all plates and a single index doesn't apply at
  the file level. bed_type keeps the first plate's value as a
  best-effort default. Malformed prediction / weight on individual
  plates skip cleanly rather than poison the sum.

  Root cause 2 - project rollup aggregated PrintArchive, not the
  per-run log.

  compute_project_stats and list_projects quick-stats summed
  PrintArchive.print_time_seconds / filament_used_grams / cost /
  energy_* WHERE project_id. A reprint reuses the source archive row
  and writes a new PrintLogEntry, so 3 sequential runs collapsed to 1
  archive - and that archive's numbers were already plate-1-only from
  cause 1. The Archive Print Log path was already correct because it
  drove off print_log_entries (archives.py:420 comment).

  Fix: both compute_project_stats and the list_projects quick-stats
  block inner-join print_log_entries -> print_archives WHERE
  archives.project_id. total_archives becomes COUNT(PrintLogEntry.id),
  failed_prints counts runs in failed/aborted/cancelled/stopped,
  completed_items is SUM(PrintArchive.quantity) for runs where
  status='completed', time/filament/cost/energy from PrintLogEntry.
  Orphan log rows (archive_id IS NULL post archive deletion) are
  excluded by the inner join.

  Same-shape fixes carried forward (no follow-ups per project rule):

  system.py system-info totals: total_print_time / total_filament
  had the same bug shape - summed PrintArchive directly so reprints
  collapsed to one row. Now sums PrintLogEntry.duration_seconds /
  filament_used_grams. The semantic shift is also a correctness
  improvement: the field now reflects time the printer actually spent
  printing, not slicer-estimated time.

  archives.py time-accuracy metric: estimate / actual per run where
  estimate = PrintArchive.print_time_seconds. Post-parser-fix
  multi-plate archives have file-level estimate but per-run actual =
  one plate, so ratio = N x 100% for an N-plate file. The calc now
  clamps each row to the [50%, 200%] plausibility band before
  contributing to the printer-level average; single-plate accuracy
  (the case the metric is designed for) stays fully included.

  Backfill: users with AMS spool tracking - the reporter's case - have
  per-run filament_used_grams from the tracked spool delta, so stats
  become correct immediately. Users without tracking fall back to the
  archive estimate and undercount until they reprint. Archive card
  still reads PrintArchive.filament_used_grams directly so old
  multi-plate archives keep plate-1-only numbers until reslice -
  forward-only as the reporter accepted.
2026-06-02 13:35:06 +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 db9b20631c fix(cloud): #1575 surface actionable error when Bambu Cloud Cloudflare challenge swallows the JSON response
When Cloudflare in front of bambulab.com returns a "Just a moment..." interstitial
  instead of the JSON the API normally produces, the parse error in
  verify_totp / verify_code / login_request used to surface as the opaque "Invalid
  response from Bambu Cloud" or a generic 401 from BambuCloudAuthError. Reporter
  hit this with three back-to-back TOTP attempts; a curl from a different network
  with the same honest Bambuddy UA returns clean JSON, so the trigger is CF-side
  (per-IP / TLS-fingerprint / rate / transient mitigation), not our code.

  Add a small _detect_cloudflare_challenge() helper that inspects the response for
  four CF markers (body "Just a moment...", body "challenges.cloudflare.com", 403
  with cf-mitigated header, 503 with cf-ray header) and returns a message that
  attributes the block to Bambu Lab's Cloudflare protection, suggests waiting a few
  minutes, and points the user at a same-network browser sign-in as the standard
  workaround. Wired into all three JSON-parse sites; verify_totp previously had a
  defensive catch, login_request and verify_code now do too.

  No header changes, no impersonation, no retry loop - pure diagnostics. Stays
  clearly on the right side of Bambu Lab's "no falsified client identity" line.
2026-06-02 10:55:25 +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 4343bd60b1 fix(notifications): honest UA + Cloudflare-challenge detection on ntfy (#1534)
The notification service's httpx client was the only outbound client in
  the codebase still leaking python-httpx/<version> as User-Agent; all
  other clients identify as Bambuddy/1.0 since the May 2026 compliance
  pass. Bring it in line.

  The reporter's ntfy server was behind a Cloudflare Tunnel and CF returned
  its JS challenge page (Just a moment...) to every API request — confirmed
  by reproducing the same 403 with curl. Cloudflare can't be solved from a
  backend, so add detection for the challenge shape (Server: cloudflare or
  cf-mitigated header, or <!DOCTYPE html>...Just a moment... body) and
  return an actionable error message that points at the real fix on the
  user's CF side instead of dumping the raw HTML.

  Normal 403s (auth failures with plain text bodies) still surface the
  original body so genuine errors stay debuggable.
2026-05-26 11:21:03 +02:00
maziggy eb98521e93 fix(test): use /nonexistent/ instead of /tmp/ to satisfy Bandit B108
The test_returns_empty_when_3mf_missing test sets a deliberately
  non-existent file_path on a PrintArchive to verify
  compute_deficit_for_queue_item handles the missing-3MF branch
  gracefully. The path just needs to fail an existence check — the
  /tmp/ prefix was incidental.

  Bandit B108 ("insecure temp file usage") regex-matches /tmp/,
  /var/tmp/, and /dev/shm/. Dropping /tmp/ in favour of /nonexistent/
  keeps the test behaviour identical (still a guaranteed-missing
  path, still triggers the missing-file branch) while clearing the
  GitHub Advanced Security finding on PR #1514 without adding a
  # nosec annotation.
2026-05-24 12:03:11 +02:00
maziggy ed08ed3787 fix(timelapse): capture baseline on restart-recovery so post-reboot timelapses attach (follow up issue #1485)
When Bambuddy is restarted mid-print, the first MQTT push from the
  printer carries `_previous_gcode_state = None`. The #1304 guard
  deliberately suppresses on_print_start on that first push to prevent
  duplicate archive creation — but on_print_start is also where
  _capture_timelapse_baseline_at_start runs, so the in-memory
  _timelapse_baselines dict stays empty for the resumed session.

  At PRINT COMPLETE, _scan_for_timelapse_with_retries finds no baseline
  and falls into its "take baseline now" fallback. By that point the
  printer has already uploaded the in-flight MP4, so the snapshot
  includes the new file. Every "Found N files / no new files since
  baseline" retry then fails to detect a diff, and the archive ends up
  with no timelapse attached — pwostran's report (#1485 follow-up): card
  shows the finish snapshot but no video.

  Add a sibling callback on_print_running_observed that bambu_mqtt fires
  in the "Now tracking RUNNING state" branch when on_print_start was
  suppressed. main.py wires it to a thin handler that looks up the
  printer row and calls the existing _capture_timelapse_baseline_at_start.
  Idempotent — skips if a baseline already exists (handles the rare
  same-session race where on_print_start also fires for some reason).

  The printer doesn't upload the timelapse until after PRINT COMPLETE,
  so a baseline captured any time during the print is still pre-upload —
  no narrow window to hit.

  Verified against the in-the-field logs in #1485 (pwostran's 2026-05-23
  support bundle):
    pre-reboot: baseline = 7 files
    reboot
    post-reboot completion: fallback baseline = 8 files (includes new MP4)
    -> all 4 retry attempts report "no new files since baseline"

  10 new tests cover both the MQTT-side fire decision (fires when
  suppressed, doesn't fire when on_print_start handles it, once per
  session, payload shape mirrors on_print_start) and the main.py
  handler (snapshot capture, double-capture guard, missing-printer-row
  guard).
2026-05-23 14:52:04 +02:00
maziggy 4686d108ef feat(slice): cross-printer re-slicing across nozzle classes + multi-plate slice-all
Re-slicing a 3MF authored for a single-nozzle printer (X1C, P1S, A1, P2S)
  onto a dual-nozzle printer (H2D / H2D Pro) — or vice versa — previously
  failed with "G-code in unprintable area of multi-extruder printers" (the
  source's bed-coordinate layout lands in the H2D's per-nozzle dead zone)
  or, on multi-color projects, a hard SIGSEGV inside the slicer's ZFiller
  polygon-clipping. Earlier shipped a fail-fast 400 guard; this drop lifts
  it and actually does the conversion by forwarding the sidecar's existing
  --arrange flag when the source and target nozzle classes differ. BS
  itself reconciles the embedded project_settings.config against the new
  printer that way, the same way the GUI's "Switch Printer" operation
  does. The guard becomes a kept-for-compat no-op.

  Slice-all-plates added to the SliceModal: a checkbox for multi-plate
  sources sends plate=0 to the backend, which forwards --slice 0 to the
  BS CLI. Same-class slice-all produces one multi-plate output 3MF in a
  single sidecar call. Cross-class slice-all loops per plate (BS's
  --arrange is project-wide and would otherwise consolidate every plate's
  objects onto one bed) and merges the per-plate outputs into one
  multi-plate 3MF locally via the new merge_plate_3mfs helper. The toast
  shows "Plate 2 of 5 — Generating G-code (47%)" through the loop.

  Three side fixes surfaced during testing:
  - substitute_unused_plate_filaments overwrites unused-slot filaments
    with the slot-1 selection before slicing so BS's loaded-filament
    temperature validator doesn't reject a PLA print whose unused slot 2
    defaulted to ABS in the dropdown
  - re-sliced archive thumbnail now prefers the source's per-plate
    render (Metadata/plate_N.png) over the project-wide MakerWorld cover
    art, because BS CLI with --arrange skips writing a fresh per-plate
    preview
  - re-sliced archive bed_type now lifts from the sliced output's
    curr_bed_type onto the PrintArchive column the card actually reads

  Schema: SliceRequest.plate range relaxed from ge=1 to ge=0 to admit
  the "all plates" sentinel; SlicerApiService.slice_with_profiles /
  slice_with_bundle take an `arrange` parameter.

  Tests: 26 in test_slicer_3mf_convert (count / merge / substitute /
  extract), 3 in test_slicer_api (arrange wire format), 9 in
  test_library_slice_api (guard no-op, bed_type lift, thumbnail
  fallback, new cross-class slice-all loop integration test), 2 in
  test_archive_service (Auxiliaries fallback), 4 in SliceModal.test
  (plate=0 toggle), 2 in SliceJobTrackerContext.test (multi-plate toast
  prefix). 659 backend + 42 frontend green; backend ruff clean,
  frontend build clean, i18n parity green at 4984 keys × 9 locales.
2026-05-23 12:16:34 +02:00
maziggy 7ea4410b21 fix(queue): insufficient-filament warning now fires on every dispatch path (#1496)
The pre-print deficit warning from #720 only ran inside the PrintModal
  submit flow. Both the green ▶ button on a staged queue row (POST
  /queue/{id}/start) and the Virtual Printer queue-mode intake bypassed
  it — auto_dispatch=True VP intakes would dispatch unsupervised onto
  spools that physically can't complete the print.

  Extracted the deficit check into backend/app/services/filament_deficit.py
  (single source of truth, both internal inventory and Spoolman modes).
  POST /queue/{id}/start returns 409 with a structured deficit payload
  unless ?skip_filament_check=true. The dispatch scheduler runs the same
  check before each _start_print; a deficit promotes the item to
  manual_start + sets a new filament_short flag (idempotent migration on
  print_queue). The flag clears automatically on the next tick when the
  operator swaps a spool to one with enough material.

  Frontend ▶ catches the 409 and opens a confirm modal showing each
  shorted slot's required vs remaining grams; the row now renders a
  yellow "Insufficient filament" badge when filament_short is set.
  Translated across all 9 locales.
2026-05-23 08:47:46 +02:00
maziggy e222a0ef0e feat(system): log-health scanner + Add/Edit-Printer setup pre-flight
Adds a passive log-health check that complements the active Connection
  Diagnostic. Scans Bambuddy's recent app log against a curated allowlist
  catalog of known failure signatures (rejected access code, FTPS :990
  timeout, FTPS TLS failure, flapping MQTT, unreachable camera, SQLite
  "database is locked" contention), dedupes and classifies each finding
  as layer8/environment/bug, and deep-links to the troubleshooting wiki.
  Sample log lines are sanitized before they leave the process. Exposed
  via GET /system/health and surfaced on two surfaces sharing one
  SystemHealthPanel component: a System Health section on the System
  page, and inline in the bug reporter when the form opens.

  The Add-Printer and Edit-Printer dialogs gained a setup-time pre-flight:
  saving runs the connection diagnostic and, on a failed check, warns with
  a "save anyway" escape hatch instead of silently saving a printer that
  will immediately show offline.

  Log read/parse/sanitize primitives extracted from routes/support.py into
  a shared services/log_reader.py (behaviour-preserving); affected support
  tests repointed accordingly.

  Tests: test_log_health.py (11), test_system_api.py (2 new),
  SystemHealthPanel + BugReportBubble + AddPrinterPreflight +
  EditPrinterPreflight (8 frontend). All strings translated across the 9
  locales. Backend ruff clean, full unit suite green, frontend build +
  eslint clean, i18n parity green.
2026-05-22 16:31:16 +02:00