Commit Graph
1354 Commits
Author SHA1 Message Date
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 4d16faed76 feat(file-manager): sort folder tree by recent activity (#1770)
Reporter has a lot of nested cad / slicer directories and wanted
  "folders that just got a new 3MF" surfaced without scrolling the
  alphabet. Tree was always alphabetical; LibraryFolder.updated_at
  only bumps on rename / move, not on file-add inside the folder.

  Backend exposes latest_activity_at = max(folder.updated_at,
  max(immediate-child file.updated_at)) on FolderResponse +
  FolderTreeItem. The /folders tree route picks up a sibling
  func.max(updated_at) group-by alongside the existing file-count
  subquery; the by-project / by-archive / single-folder routes
  collapse count + max into one trip. Recursion across subfolders
  is intentionally not computed - bubbles immediate parent only,
  keeps the query a single GROUP BY rather than a recursive CTE.

  Frontend adds a folder-sidebar sort dropdown (By name / By recent
  activity) plus an asc / desc arrow, persisted in localStorage.
  sortedFolders memo applies the comparator recursively so order is
  consistent at every depth. Empty folders fall back to name within
  the activity bucket so they never elbow a recently-used folder to
  a random position. Both the desktop sidebar and the mobile selector
  consume the sorted list so order is identical across breakpoints.

  External folders: LibraryFile rows are created for scanned external
  files too, so the aggregate works on them - but the timestamp
  reflects last scan, not filesystem mtime. Documented in the wiki.

  Same change also fixes File Manager list-view column alignment:
  header and body were sibling grids with min-content as the trailing
  column, computed independently. Header empty trailing div resolved
  to 0; body action strip to ~220px. Different trailing widths gave
  the 1fr Name column different remaining space, shifting every fixed
  column to its right. Replaced min-content with fixed 220px in both
  auth-on / auth-off grid templates.
2026-06-21 13:31:50 +02:00
maziggy 68b9d741d9 feat(humidity): per-filament humidity threshold for auto-drying + alarms (#1605)
Reporter @thenewguy runs an engineering farm with one AMS per material
  (PLA, ASA, Nylon, PVB, HIPS) — Bambuddy's single global ams_humidity_fair
  threshold (default 60%) was driving both the queue / ambient auto-drying
  trigger AND the hourly humidity alarm uniformly, which is wrong for
  multi-material setups where Nylon wants <10% and PLA is fine at 60%.

  Drying RUN parameters were already per-filament via drying_presets;
  this commit adds the missing per-filament TRIGGER.

  New setting ams_humidity_thresholds — JSON map of filament-type to
  threshold percent with a "default" key for unknown / unmapped types.
  Empty / unset → both consumers fall back to ams_humidity_fair so the
  upgrade is silent.

  Resolver lives in PrintScheduler.resolve_humidity_threshold(trays,
  thresholds, fallback) — picks the lowest (most-restrictive) threshold
  across all loaded tray types, matching the conservative-params strategy
  _get_conservative_drying_params already uses for temp / hours. Empty
  tray slots contribute no constraint; all-empty AMS falls through to the
  "default" key. Filament names normalized to uppercase base (so
  "PLA Basic" / "pla basic" both map to PLA).

  Two consumer sites rewired through the same resolver so the scheduler
  and the alarm path can never disagree about whether an AMS is "too
  humid":
    - print_scheduler.py::_check_auto_drying — per-AMS humidity comparison
      for start / stop / skip decisions.
    - main.py AMS sensor / alarm worker — hourly humidity alarm notifier.

  UI: new table in Settings → Workflow → Auto-Drying, below the existing
  Drying Presets table. Default row + 8 default filament types
  (PLA / PETG / TPU / ABS / ASA / PA / PC / PVA) pre-filled from the
  current ams_humidity_fair value so the editor starts sensibly.

  Input pattern: draft-on-edit / commit-on-blur (transient humidityDrafts
  state per row). onChange only updates the draft; onBlur (and Enter)
  parses + clamps to [5, 95] + commits. Empty value on blur clears the
  override and falls back to default. Caught mid-PR via a typing test:
  the naive per-keystroke clamp snapped "3" → 5 before the user could
  type the second digit of "30".

  Setting is in the public _UI_PREFERENCE_FIELDS allowlist (same rationale
  as drying_presets and ams_humidity_fair — non-sensitive integer map,
  no SETTINGS_READ permission required for badge-color rendering).
2026-06-21 11:55:37 +02:00
maziggy 25a23eadd7 fix(updates): switch Windows installer installs to release-asset update flow
In-app "Install Update" on Windows installer installs failed with "Could
  not find git executable" because (1) _find_executable's fallback paths
  are Unix-only, and (2) the installer stages backend/ via shutil.copytree
  so there is no .git directory — even with Git for Windows installed, the
  fetch would die on "not a git repository". Adding Windows paths would
  only have changed which error users saw.

  Switches the Windows installer path to a fourth update_method
  ("windows_installer") that mirrors the existing docker / ha_addon
  branches — surface a link to the release .exe and let the user re-run
  the installer, matching the Discord / Spotify Windows update model.

  Backend:
  - New _is_windows_installer_install() — true iff sys.platform == "win32"
    AND no .git in app_dir, so Windows devs with a real git clone keep
    the git path.
  - New _find_windows_installer_asset() picks the matching release asset
    (prefers versioned bambuddy-<ver>-windows-x64-setup.exe, falls back
    to the unversioned alias on non-daily tags).
  - /updates/check now returns is_windows_installer / update_method /
    installer_download_url.
  - /updates/apply short-circuits with a friendly message after the
    existing HA / Docker guards — defense in depth, the frontend swaps
    the button so the POST should not fire on Windows.

  Frontend:
  - UpdateCheckResult extended with the new fields and 'windows_installer'
    in the update_method union.
  - SettingsPage renders a Bambu-green styled <a target="_blank"
    rel="noopener"> between the Docker snippet and the in-app Update
    button, with installer_download_url falling back to release_url then
    the tag page so the link is never broken.
  - applyUpdateMutation onSuccess toast guard extended to treat
    is_windows_installer the same as HA / Docker.
2026-06-21 10:25:57 +02:00
maziggy a5fe5cb3d4 feat(sponsor-prompt): in-app toast at earned milestones
ghcr.io pull baseline (~10k/day rising → ~8-12k active installs) puts
  sponsor conversion at 0.08% — roughly an order of magnitude under
  industry-benchmark for OSS with visible CTA. The Settings banner from
  0d4b9d4e gives passive every-visit visibility on one page; this adds
  opt-out-able active visibility at moments where the user has just
  earned something with Bambuddy.

  Five trigger families with a 14-day cross-family cooldown: prints
  (100/500/1000/2500/5000), cost (100/500/1000 tracked filament +
  energy), archives (50/250/1000), anniversary (1 year), version-update
  (re-armable on each major bump). New sponsor_toast_state table with
  nullable user_id so auth-disabled installs get the same trigger logic
  through one code path (NULL-keyed install-default row).
2026-06-20 15:50:58 +02:00
maziggy 090c180ebf feat(heater-history): track nozzle / bed / chamber readings + per-tile chart-icon overlay opens history modal
New PrinterSensorHistory table + 60s recorder + GET/DELETE /printer-sensor-history
  route gated behind a new PRINTER_SENSOR_HISTORY_READ scope (separate from AMS). UI
  adds a 10x10 LineChart icon on each heater tile - click body opens the existing
  target-temp popover unchanged, click icon opens a HeaterHistoryModal mirroring the
  AMSHistoryModal shape (kind toggle + 6h/24h/48h/7d range + current/avg/min/max +
  recharts line for value + dashed target). Read-only X1C/P2S chamber tile finally
  gets an interaction. Retention configurable via printer_sensor_history_retention_days
  (default 30, sibling of ams_history_retention_days). 8 new i18n keys translated in
  all 11 locales, parity green. 4 backend + 6 frontend tests added; full pytest -n 30
  6226/6226, vitest 2176/2176, ruff/eslint/build all clean.
2026-06-20 12:55:24 +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 2f6007a148 fix(notifications): scope completion notification to printed plate on multi-plate 3MFs (#1785)
The 3MF parser sums prediction + weight across every plate (#1593) so the
  archive card can headline the whole project — correct for the card, wrong
  for the completion notification of a single plate. The queue UI already
  re-reads the 3MF per-plate at print_queue.py:272-285; mirror that for the
  notification path so Discord / Pushover / email show the plate's actual
  duration and grams instead of the project sum. Helper fails open on every
  error path so a missing or corrupt 3MF can't block the notification.
2026-06-20 08:28:44 +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
maziggy ceb0616c82 chore(deps): backend security floor bumps + 422 constant rename
requirements.txt
    - cryptography 46.0.7 -> 48.0.1 floor (GHSA-537c-gmf6-5ccf,
      non-contiguous Python buffer handling)
    - python-multipart 0.0.27 -> 0.0.31 floor (CVE-2026-53538/53539/53540,
      multipart parser hardening)
    - starlette 1.1.0 -> 1.3.1 floor (CVE-2026-54282/54283, FormParser
      limit enforcement + StaticFiles absolute-path rejection)
    - pyopenssl 26.0.0 -> 26.3.0 floor (NOT a security fix; pyOpenSSL
      <26.3.0 caps cryptography<47 and would otherwise downgrade out
      of the GHSA-537c-gmf6-5ccf fix line)

  backend/app/api/routes/mfa.py
    - 3x HTTP_422_UNPROCESSABLE_ENTITY -> HTTP_422_UNPROCESSABLE_CONTENT
      (the former is deprecated in starlette 1.3.x, same 422 wire status;
       the 2 remaining warnings are inside FastAPI itself, upstream's)

  Release-notes review done before bump: cryptography 47/48 dropped
  binary EC, CFB/OFB/CFB8, Camellia, PUBLIC_KEY_TYPES/PRIVATE_KEY_TYPES,
  OpenSSL 1.1.x, Python 3.8 -- grep clean against every removed surface;
  starlette's newly-enforced max_part_size=1MB only applies to text form
  fields (verified in MultiPartParser.on_part_data), file streams from
  UploadFile = File(...) are unaffected; python-multipart 0.0.30 dropped
  RFC 2231/5987 filename* parsing, minor cosmetic impact on non-ASCII
  filename uploads, plain filename= fallback still works.
2026-06-19 12:02:30 +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 9f8bac63ff fix(makerworld): resolve API-key owner for cloud-token lookups (#1777)
The makerworld /status, /resolve, and /import handlers passed
  current_user directly into get_stored_token / _build_service.
  require_permission_if_auth_enabled returns None for API-keyed
  callers by design (core/auth.py:1414), so the lookup always
  missed even when the key's owner had a stored Bambu Cloud session.
  Result: a "requires a Bambu Cloud login" 400 on every API-keyed
  import, regardless of the owning account's actual cloud state.

  Wire resolve_api_key_cloud_owner (already used by the slice path
  in #1182 — slicer_presets.py:491 and library.py:3871) into the
  three makerworld routes that read the cloud token. The handler
  falls back to the API-key owner via cloud_token_user =
  current_user or api_key_cloud_owner, then passes that through.
  import_instance also propagates the resolved user to the
  owner_id arg on save_3mf_bytes_to_library, so the resulting
  LibraryFile.created_by_id reflects the key's owner instead of
  NULL.

  Fail-closed semantics preserved: resolve_api_key_cloud_owner
  already fences on api_key.can_access_cloud, so keys with only
  the per-route scope (can_read_status / can_manage_library) still
  take the existing "requires Bambu Cloud login" path — no auth
  widening.

  /recent-imports is unchanged — it only uses current_user as a
  permission gate (_ = current_user) and never touches the cloud
  token.
2026-06-19 08:05:20 +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 52448a374e test(settings): include preset fields in /ui-preferences pin assertion
Follow-up to the temperature & fan-speed presets feature — the
  TestUiPreferencesEndpoint.test_returns_expected_field_set test pins
  the exact set of fields the endpoint exposes (so adding a sensitive
  field by accident fails the assert). The 4 preset fields were added
  to _UI_PREFERENCE_FIELDS without updating the pin, breaking the full
  backend test run.
2026-06-18 12:07:56 +02:00
Ed 0f99b54d7e feat: Update printer card UI for structure and readability (#1661) 2026-06-18 11:46:07 +02:00
maziggy 9d1f04b89d log(scheduler): emit prefer-lowest dispatch decision at INFO so #1766 can be triaged from a bundle
The matcher's tray_info_idx vs color vs type_only bucket choice was
  debug-only, so a bug report's bundle never showed which path won. Emit
  the sorted candidate trays and the picked bucket per filament req when
  prefer_lowest=True. Behaviour-neutral; existing 89 matcher tests pass.
2026-06-18 08:22:22 +02:00
maziggy 9a432f0050 Restrict printer secrets to update-authority callers
GET /api/v1/printers/ and /api/v1/printers/{id} return access_code
  only when the caller holds PRINTERS_UPDATE. Adds PrinterResponseWithSecret
  as the elevated response shape; PrinterResponse no longer carries the
  field. Auth-disabled single-trust mode preserved.
2026-06-18 07:36:16 +02:00
Ed 48d3f68ae6 feat: Centralise sidebar ordering and add page visibility options (#1673) 2026-06-17 12:04:57 +02:00
Poltavtcev af5d24e289 feat(inventory): structured storage locations catalog (#1505) 2026-06-17 11:33:23 +02:00
maziggy 2940fbdcf7 feat(auth): admin-configurable session lifetime ceiling (#1706)
The 24h session cap from the M-2 audit finding was hard-coded, so the
  "Remember Me" checkbox could only control storage location, never
  duration. Add session_max_hours setting (default 24, max 720) honoured
  at all four token-issuance sites: plain login, 2FA TOTP/email, 2FA
  backup, OIDC.

  - backend/app/core/auth.py: SESSION_MAX_HOURS_HARD_CEILING + resolver
    that clamps to [1h, 720h] and falls back to 24h on missing/blank/
    unparseable. DB errors propagate — the login transaction must abort
    on a broken DB rather than silently extend or shrink the lifetime.
  - backend/app/api/routes/auth.py, mfa.py: all four sites read the
    resolved value instead of ACCESS_TOKEN_EXPIRE_MINUTES directly.
  - backend/app/schemas/settings.py, routes/settings.py: schema field
    with ge=1 le=720 + int coercion in _build_settings_response.
  - frontend/src/pages/SettingsPage.tsx: half-width card at top of
    Settings -> Users left column with 24h/7d/30d presets, custom input,
    and a yellow warning when value > 24h.
  - frontend/src/i18n/locales/*.ts: 8 new keys per locale, real
    translations in all 11 (en/de/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW).
  - backend/tests/integration/test_session_policy.py: 15 tests across
    resolver clamping, login JWT exp end-to-end, settings API round-trip.

  Already-issued tokens keep their original expiry; the new setting only
  affects future logins.
2026-06-16 12:00:27 +02:00
maziggy eb5154f61a feat(queue): tabbed page, batch grouping, multi-drag, Gantt timeline
Restructures the queue page around three tabs (Queue / History / Timeline)
  and adds first-class batch grouping plus a real time-based timeline.

  Queue tab
  - Layout toggle: Sort by Position (flat list) or Group by Printer (per-
    printer section cards with aggregate count / time / weight headers).
  - Batch grouping: pending items sharing a batch_id render as a single
    collapsible row with aggregate stats; children draggable within the
    batch only. Per-batch collapse state in localStorage.
  - Multi-drag: dragging any selected row moves all selected items as a
    contiguous block via DragOverlay (+N ghost).
  - Selection bar gains a Group as batch action when 2+ ungrouped items
    are selected. Ungroup lives on the batch parent row.

  History tab
  - Two-line rich rows: filament color swatch + weight + type, user
    attribution, inline error message on failed / skipped rows.
  - Responsive 1 / 2 / 3 column grid so a long history uses available
    width instead of stretching one row per line.
  - Batch siblings group into a collapsible parent with status-rollup
    chips (3 OK / 1 failed / etc).
  - Thumbnail hover preview shows the full image at 192x192 next to the
    small thumb.

  Timeline tab
  - Replaces the hourly-list view with a Gantt swimlane: one row per
    printer (plus per target_model and unassigned), horizontal hour
    axis, jobs as bars positioned by start time and sized by duration.
  - Live NOW marker.
  - Only committed schedules are rendered: currently printing items,
    pending items with scheduled_time, and pending ASAP behind an active
    print. Staged (manual_start), waiting (waiting_reason), and ASAP
    jobs on idle printers are filtered out.
  - 24h rolling window with 12h step controls.
  - Per-bar tooltip with start, end, progress, batch name.

  Backend
  - POST /queue/batches creates a batch, optionally assigning existing
    pending item_ids (manual grouping) or returning an empty batch the
    client can attach to subsequent /queue/ POSTs.
  - POST /queue/batches/{id}/ungroup clears batch_id from all members
    (skipping items the caller does not own) and deletes the batch row
    when no members remain.
  - POST /queue/ accepts an optional batch_id and validates that the
    batch exists, is active, and the caller may modify it. The existing
    quantity > 1 auto-batch path still fires when no batch_id is sent.

  PrintModal
  - When N plates from one source are queued in a single submission
    (model assignment or single printer), the modal pre-creates a batch
    and passes its id to each addToQueue call so multi-plate jobs land
    grouped automatically. Falls back to ungrouped items if the batch
    pre-create fails.
2026-06-16 10:45:30 +02:00
maziggy ead6c37147 fix(notifications): wire on_printer_offline dispatch on disconnect edge (#1752)
The provider toggle, schema, template, and NotificationService.on_printer_offline
  all shipped, but no caller invoked the dispatcher — the offline event was an
  orphan toggle. Edge detection in on_printer_status_change now schedules a
  debounced (60s) background task on the connected→disconnected transition;
  reconnect before the window elapses cancels it. Covers both upstream paths
  (smart-plug power-off via mark_printer_offline, and MQTT staleness via
  check_staleness), both of which already route through the status callback.
  The "back online" channel is the existing print-failure notification on
  firmware FAILED report — no symmetric on_printer_online needed.
2026-06-16 07:34:14 +02:00
maziggy b171980688 fix(archives): backfill NULL created_at + tolerate NULL in response (#1732)
Older print_archives rows (and rows that landed via the SQLite ↔ Postgres
  cross-DB restore path) can have created_at = NULL because the column was
  originally created without a DEFAULT clause — server_default=func.now()
  only fires at table creation, not for existing rows or raw cross-DB
  inserts. The list_archives response model required a datetime, so a
  single NULL row 500'd the whole endpoint via Pydantic ResponseValidationError.

  - Boot-time backfill: COALESCE(completed_at, started_at, now()) for
    any row where created_at IS NULL. Dialect-branched (SQLite datetime('now')
    vs Postgres NOW()).
  - Schema: created_at is now Optional on ArchiveDuplicate, ArchiveResponse,
    and ArchiveSlim so a future NULL-leaking path doesn't break the list
    endpoint again.
2026-06-14 12:21:57 +02:00
maziggy 68c06a76c4 chore(support): silence bandit B104 on net.info ip redaction
The "0.0.0.0" written into the support bundle is a JSON sentinel that
  scrubs the printer's local IP plus the gateway/peers it sees — not a
  socket bind address. Annotate inline so bandit stops flagging it.
2026-06-14 10:23:02 +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 43adb6f964 Security hardening (security #2) 2026-06-13 09:35:49 +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 f15e54c383 fix(windows): _local_zone falls back to stdlib utc when zoneinfo DB is missing
The Windows installer's embedded Python doesn't carry an IANA tz
  database, and the stdlib zoneinfo has no system DB to read on Windows.
  ZoneInfo("UTC") raises ZoneInfoNotFoundError on those installs, and
  the new /api/local-backup/status endpoint 500s on the resulting
  uncaught exception. Surfaced via a Windows traceback from a user's log:

    File "...\backend\app\services\local_backup.py", line 32, in _local_zone
      return ZoneInfo("UTC")
    zoneinfo._common.ZoneInfoNotFoundError: 'No time zone found with key UTC'

  _local_zone()'s try/except only covered the TZ-env branch — both
  fallbacks unconditionally called ZoneInfo("UTC") and re-raised.

  Fix (two parts):

  1. services/local_backup.py — return type widened from ZoneInfo to
     tzinfo, the UTC fallback is wrapped in its own try, and the
     last-resort fallback returns datetime.timezone.utc (stdlib, no
     IANA DB needed). str(timezone.utc) == "UTC" so the response shape
     on /api/local-backup/status is unchanged. The astimezone call in
     _calculate_next_run accepts any tzinfo — no other call sites
     affected.

  2. requirements.txt — pin tzdata>=2024.1; sys_platform == "win32" so
     the next Windows installer build ships the IANA DB, and any non-
     UTC TZ value (e.g. Europe/Berlin) resolves correctly. The stdlib
     fallback can only ever give UTC. Linux/macOS unaffected by the
     platform marker — they already have the system tz database.
2026-06-13 07:49:31 +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 1bcd5c8ba5 feat(support): bundle redacted cached push_status per connected printer
The support bundle shipped support-info.json + bambuddy.log, but the raw
  shape of the printer's MQTT push_status — the field that blocks per-model
  work like AMS Backup detection (deferred in 85fbd7fc) and every vt_tray /
  vir_slot / mapping shape regression — was never captured.

  Each connected printer now contributes push-status/printer-{i}.json with
  {model, firmware_version, captured_at, raw_data}, indexed against
  support-info.json["printers"]. Two-pass redaction: a structural walk
  drops user-private keys (subtask_name, gcode_file, subtask_id, task_id,
  project_id, design_id, profile_id, model_id, gcode_state,
  gcode_file_prepare_percent) and rewrites net.info[*].ip to 0.0.0.0
  (matches the #1429 VP bridge fix); then the JSON runs through the same
  DB-derived sensitive_strings sanitizer the log path uses, catching any
  printer name / serial / access code / cloud email that leaked into a
  nested string field.

  print.cfg, print.option, ams, vt_tray, vir_slot, mapping,
  ams_extruder_map, and hardware fields are all preserved — those are the
  fields per-model work needs.

  Always-on inside the existing debug-logging-required gate; no opt-in
  toggle (the bundle is already user-initiated and downloads locally
  before the user chooses to send).
2026-06-12 14:52:21 +02:00
maziggy 9c4252911b fix(vp): overlay incoming dict-shaped push_status fields onto cache instead of replacing (#1622 round 5)
Right after the slicer picks a filament for the external spool (vt_tray, ams_id=255),
  Bambu firmware pushes a partial vt_tray carrying just {tray_info_idx, tray_color} -
  ~18 fields shorter than the pushall shape the slicer expects. The #1622 round-4
  per-field accumulate (da799447) only carried over prev keys NOT in new, so the
  cached vt_tray was replaced wholesale with the 2-field partial. The next 1 Hz
  cached-as-base push delivered the stripped dict and BambuStudio rendered the
  external slot as invalid (color only, no tray_type / state / k / n / cali_idx /
  nozzle_temp_*). Reload restored it because the reconnect-triggered pushall
  re-seeded vt_tray, then the cycle repeated. AMS slots didn't suffer because
  _merge_ams_dict deep-merged them.

  Fix: for every top-level push_status key whose prev AND new are both dicts,
  overlay incoming keys onto prev rather than replace. ams is excluded (already
  deep-merged). The same shape protects device / online / upgrade_state / ipcam /
  upload / net against future firmware partials. net.info IP rewrite is unaffected -
  _rewrite_net_info_ips runs before caching and overlay lets the freshly-rewritten
  list win over prev when present.
2026-06-12 14:04:15 +02:00
maziggy 912f9feba2 test(users/groups): generate privilege-escalation test password at runtime
GitGuardian still flagged the file after the previous round even though
  every call site used a constant — the constant itself was a static
  string built by concatenation, which the generic-password detector still
  matched on. Generate the test credential per process via secrets.token_urlsafe
  so no password literal lives in the source, and mark the single line where
  the variable is bound with the standard `pragma: allowlist secret` marker
  ggshield / detect-secrets honour.
2026-06-12 13:27:17 +02:00
maziggy d45bcb87ed test(users/groups): hoist privilege-escalation test passwords to a fixture constant
GitGuardian flagged the seven hard-coded passwords used by the
  privilege-escalation regression suite as potential secrets. They are
  test-only credentials whose value is irrelevant — the suite asserts
  the admin authorization gate, not password handling — but the pattern
  matches the high-confidence detector.

  Replace each call-site literal with a single _FIXTURE_PW module
  constant, built from string concatenation so it doesn't hash to a
  recognisable token, with a comment explaining the purpose and the
  complexity rule it satisfies. No behavioural change; all 11 tests
  still pass.
2026-06-12 13:22:45 +02:00
maziggy f2a3917e90 Security hardening (maziggy/bambuddy-security #1) 2026-06-12 11:11:38 +02:00
maziggy 857a071306 fix(library): preview sidecar-sliced .gcode.3mf rows as G-code, not ZIP bytes (#1709)
slice_and_persist writes a .gcode.3mf ZIP container but persisted the row
  with file_type="gcode". The G-code preview endpoint short-circuits on
  file_type == "gcode" and returns the bytes as text/plain, so the embedded
  viewer received the raw ZIP body instead of the embedded toolpath.

  - Persist file_type="gcode.3mf" on sliced rows (matches _classify_file_type
    and external-scan rows).
  - get_gcode also routes to the unzip branch when the filename ends with
    .gcode.3mf, so rows already written under the bug self-heal on first
    preview without a DB migration.
  - Extend FileManagerPage badge + viewer-eye gate and ProjectDetailPage badge
    to accept "gcode.3mf"; isSlicedFilename / isSliceableFilename already do.
  - Add test_library_get_gcode_recovers_legacy_gcode_type_for_3mf: legacy
    row preview must be text/plain, contain G28, and NOT start with PK.
2026-06-12 10:42:02 +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 da799447f6 fix(vp): accumulate cached push_status per-field instead of allowlist (#1622)
Bridge cache replaced prev state wholesale on each incremental, re-merging
  only a 14-key allowlist. Capability/lifecycle fields (cali_version,
  print_type, mc_print_stage, device, ...) drained out within one 1Hz tick,
  greying out BambuStudio's Device-tab UIs (manage-calibration, AMS-slot
  dropdown) once the cache thinned. Most P1S users miss it by timing — they
  click Device tab while the cache is still fat from the connect pushall.

  Switch to per-field accumulate matching bambu_mqtt.py's internal state
  handler: prev keys carry over verbatim when not present in the incoming
  push, new values overwrite when present. _merge_ams_dict for partial AMS
  blobs unchanged (#1387 / #1371 regression guards stay green).
  _SLICER_VISIBLE_STICKY_KEYS removed — new logic is a strict superset.
2026-06-11 17:23:49 +02:00
maziggy 282aefc564 Sync and housekeeping 2026-06-11 15:39:02 +02:00
maziggy 5644f11495 debug(vp): env-flagged bridge-synthesised reply trace for slicer↔printer #1622 round-3 triage
Round-2 cmd.jsonl from shaddowlink proves the bridge forwards both commands and
  responses correctly: ams_filament_setting round-trips with result=success on P1S,
  the cached push_status carries tray_info_idx=GFA11/tray_type=PLA-AERO/K-n/cali_idx
  intact, and the visible "unload" symptom comes from the slicer's choice of
  extrusion_cali_set (push K direct, P1S firmware rejects) vs extrusion_cali_sel
  (select by id, both H2D and P1S accept). The open question is what makes the
  slicer pick _set vs _sel — likely the info.get_version response Bambuddy
  synthesises or the first cached pushall reply the slicer reads at connect.
  Round 2 captured neither; the JSONL had slicer_to_bridge and printer_to_slicer
  but no direction for the bridge's own synthesised replies.

  Same BAMBUDDY_VP_DUMP_WIRE=1 flag now also appends a bridge_to_slicer line for
  every bridge-synthesised reply (info.get_version answer, project_file ack,
  on-demand pushall response). Capture is in _publish_to_report — the single
  chokepoint — gated on a new log_event param; the 1Hz periodic push threads
  log_event=False so the JSONL isn't flooded (~60 lines/min/VP) because
  dump_wire already covers cache shape per tick.

  Diagnostic-only, no data-path change. Default param preserves every existing
  call site's behaviour.
2026-06-11 15:09:24 +02:00
maziggy 6b477088a2 fix(queue): extend Charcoal-style label fix to Specific-Printer panel (#1718 round 3)
Round 2 fixed the model-mode FilamentOverride: tray_info_idx →
  sub-brand, plus a material-disambiguated colour name from a new
  /inventory/colors/by-material endpoint. The printer-mode panel that
  renders the same 3MF (FilamentMapping) was reading the same raw
  fields — item.type for the required label, getColorName(item.color)
  for the swatch tooltip — and was not touched, so picking "Specific
  Printer" still showed "Required: PLA - Black" for a slice the
  "Any H2D" branch already labelled "Bambu PLA Matte - Charcoal".

  Extract the three-query resolution machinery from FilamentOverride
  into a shared hook useFilamentLabels (returns positional
  {resolvedName, colorLabel} per slot). Both panels call it; both
  read the same labels. The hook also owns extractMaterialHint so the
  "strip leading brand token" rule has one source of truth.

  FilamentMapping required-side now reads {resolvedName} instead of
  {item.type}; swatch tooltip reads `Required: {resolvedName} -
  {colorLabel}` instead of `Required: {item.type} -
  getColorName(item.color)`.
2026-06-11 14:49:52 +02:00
maziggy 19eed8eba0 debug(vp): env-flagged command-flow trace for slicer↔printer #1622 round-2 triage
The shape-of-payload dump shipped earlier rules out cache wipes —
  shaddowlink's round-1 captures show AMS data reaches the slicer
  byte-identical to what the printer sent. The remaining symptom
  (picking a generic filament in archive mode "unloads" the slot) lives
  on the command path, which the snapshot dump doesn't see: it writes
  only the cached _latest_print_state and the periodic 1Hz push.

  Add append_event() in _debug.py — same env flag, separate file at
  <log_dir>/vp_wire/<vp>_cmd.jsonl. One JSONL line per event with UTC
  iso timestamp, direction (slicer_to_bridge / printer_to_slicer), MQTT
  topic, <channel>.<command> grep handle, and parsed payload. Wired at
  two points: mqtt_server._handle_publish for slicer publishes (after
  JSON decode so the trace matches what the bridge actually parsed) and
  mqtt_bridge._on_printer_raw "everything else" branch for printer
  responses (after serial rewrite so the trace matches what the slicer
  sees on the wire). Pushall / get_version stay out — both are handled
  locally and never round-trip through the bridge.

  Bytes payloads get the same \x00-tolerance fix from #927 so
  OrcaSlicer's C-string-null publishes parse cleanly; un-parseable
  bytes fall back to {"raw": "..."} so every line stays valid JSON.
2026-06-11 13:35:20 +02:00
maziggy 6ef0df6ca0 fix(updater): route every git step through app_dir for separate-mount installs (#1715)
Native installs that follow the systemd template
    WorkingDirectory=/opt/bambuddy
    Environment="DATA_DIR=/srv/bambuddy/data"
  (or any layout where DATA_DIR is not a subdirectory of the install)
  could not apply in-app updates. Every git subprocess in _perform_update
  used cwd=settings.base_dir and safe.directory={base_dir}. On standard
  installs (DATA_DIR=INSTALL_PATH/data) this happened to work by accident
  because git walks up from a subdirectory of the repo to find .git; on
  separate-mount layouts the walk has nowhere to go and every call
  returns "fatal: not a git repository." safe.directory was also wrong
  even on the standard install -- it must equal the repo root git
  discovers, not the data dir.

  Resolve app_dir = settings.app_dir at the top of _perform_update and
  route all four git subprocesses (remote get-url, remote set-url, fetch,
  reset --hard) and the embedded safe.directory through it. Rename the
  base_dir parameter on _origin_points_at_repo to app_dir so the
  signature documents the contract.
2026-06-11 12:40:32 +02:00
maziggy d459b6eabb fix(slicer): preset visibility + lookup precedence + signed-out banner + AMS slot badges (#1712)
Four #1712 issues from the 2026-06-04 Orca Cloud integration:

  (1) Tier order put Orca Cloud above everything across SliceModal,
  auto-pick scoring, dropdown groups, the AMS slot picker, and the
  backend precedence. Bambu-Cloud-only users saw their profiles
  deprioritised behind an empty Orca tier.

  (2) Cross-tier dedup hid a same-named preset in all but the highest-
  priority tier. A user with both a local-imported and an Orca-synced
  "Bambu PLA Basic" couldn't see the Orca copy as a picker option.

  (3) CloudStatusBanner nagged signed-out users with a permanent
  "Sign in to Orca Cloud" line at the top of every slice -- even after
  explicit logout. Bambu Cloud had the symmetric problem.

  (4) ConfigureAmsSlotModal source badges were inconsistent: Orca rows
  showed only "Custom" (no source identity), Bambu Cloud built-in rows
  had no badge at all, and the orthogonal isUser-driven "Custom" badge
  collided with the source badge for cloud user presets.

  Order is local > orca_cloud > cloud > standard everywhere it lives
  (SliceModal SLICE_MODAL_TIER_ORDER + TIER_BONUS + dropdown tier list,
  ConfigureAmsSlotModal sourceOrder, and backend precedence). The order
  drives auto-pick + visual group rendering; it does NOT hide profiles.

  _dedupe_by_name replaced with _enrich_cloud_metadata: every tier
  returns its full list across all three slots (printer / process /
  filament). The function still backfills Bambu Cloud filament metadata
  from same-named local / orca_cloud / standard entries so cloud
  filaments score in pickFilamentForSlot.

  CloudStatusBanner silently no-ops on not_authenticated for both clouds;
  expired / unreachable still surface. The not_authenticated i18n keys
  stay in the locale files dormant.

  ConfigureAmsSlotModal: one source badge per row, one colour per source
  (green Local / purple Orca Cloud / bambu-blue Bambu Cloud / amber
  Built-in). The legacy isUser-driven "Custom" badge is gone; every row
  identifies its tier consistently.
2026-06-11 12:17:45 +02:00
maziggy 4ab7339fe4 debug(vp): env-flagged wire-payload dump for slicer-mirror triage (#1622)
Add a BAMBUDDY_VP_DUMP_WIRE=1 escape hatch that writes the bridge's
  cached push_status (in) and the 1Hz slicer-facing copy (out) to
  <log_dir>/vp_wire/<vp_name>_<direction>.json, overwritten each tick.

  #1622's symptom — empty filament dropdown in slicer's AMS slot details
  for P1S/A1 but not H2D in non-proxy VP modes — needs visibility into
  the actual wire bytes flowing through the bridge to bisect between
  "cache is missing fields" and "_send_status_report strips them on copy."
  The existing logs prove the bridge is bound and pushing at 1Hz, but
  not what's in the payload.

  Off by default, single env flag, single file per VP per direction
  (bounded disk footprint), failures swallowed at debug so a broken
  dump can never break the 1Hz loop. 21 tests pin the helper contract:
  disabled-by-default, atomic writes, sanitized vp_name (no path
  escape), per-call env check so toggling without restart works.
2026-06-11 10:02:22 +02:00
maziggy bf781e0566 fix(notifications): reprint-from-archive sent original print's finish photo (#1707)
Reprints reuse the source archive row via the expected-print promotion
  branch, but that branch never reset archive.timelapse_path. The stale
  path made _scan_for_timelapse_with_retries early-return (so the new
  run's MP4 was never downloaded), and _capture_finish_photo_from_timelapse
  then extracted the *original* run's last frame and shipped it to Telegram
  as the new run's finish photo. Surface was specific to the
  timelapse-prefer path (data.timelapse_was_active=true and no external
  camera) — fallback live-camera paths were unaffected, which is why this
  took a P2S reporter to surface.

  Clear archive.timelapse_path at promotion and unlink the stale on-disk
  MP4 (best-effort; missing files are logged and skipped). The orphan
  unlink also kills a long-standing file-leak: every reprint used to leave
  its predecessor's timelapse on disk forever. archive.photos is
  deliberately left alone — accumulating one finish photo per run is
  correct.
2026-06-11 09:23:03 +02:00
maziggy 4b2babe752 fix(network-utils): enumerate interfaces on Windows via psutil
get_network_interfaces() used fcntl ioctls and get_all_interface_ips()
  shelled out to `ip -j addr show` — both Linux-only. On Windows the
  fcntl path raised ImportError and returned [], so the VP "bind IP"
  dropdown showed no interfaces.

  Add a Windows branch using psutil.net_if_addrs() + net_if_stats()
  (psutil is already a dep). Same dict shape as the Linux path, filters
  loopback / link-local / down interfaces by address class instead of
  by name prefix. No name-based exclusion — users may legitimately
  bind a VP to a Hyper-V / WSL / Tailscale virtual adapter.

  The Linux fcntl path is untouched.
2026-06-10 12:57:23 +02:00
maziggy defec334c4 feat(a2l): add Bambu Lab A2L support (#1684)
Internal code N9 (from BambuStudio resources/profiles/BBL/machine/Bambu Lab A2L.json),
  serial prefix 26A19 (5-char, same shape as H2C's late 31B8B). Capabilities from
  Bambu's official A2L specs page: linear rail, single FDM extruder + integrated
  cutter/plotter, no Ethernet (2.4 GHz Wi-Fi only), low-rate chamber camera on
  port 6000.

  The BambuStudio profile's use_double_extruder_default_texture: true flag
  describes two TOOL HEADS (FDM + cutter), not dual filament extrusion — A2L
  must NOT be classified as dual-nozzle or AMS routing will target the deputy
  slot and firmware rejects with 07FF_8012.

  Registry updates: printer_models.py, firmware_check.py, virtual_printer/manager.py,
  virtual_printer/mqtt_server.py, PrintersPage.tsx, SpoolBuddyAmsPage.tsx.
  12 new test cases in TestA2LModel pin every dimension.
2026-06-10 10:59:27 +02:00