Commit Graph
3039 Commits
Author SHA1 Message Date
maziggy e761e09297 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-28 12:38:31 +02:00
maziggy 5c16ef3e58 feat(settings): prominent sponsor banner on General tab
Matomo shows only 1.18% of website visitors reach /sponsors despite
      29% hitting /installation. The ask was discoverable on the marketing
      site but invisible in-app where users actually live.

      Full-width gradient banner sits above the three-column layout on the
      default landing tab and links to bambuddy.cool/sponsors.html with a
      ?from=app-settings tracking param so conversion lift is measurable
      in Matomo. Three new sponsors.* keys translated to all 11 locales.
2026-06-28 12:38:11 +02:00
maziggy d1d166592c 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-28 12:37:49 +02:00
maziggy a70c2a2dc4 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-28 12:37:26 +02:00
maziggy 99c6949b5c 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-28 12:37:00 +02:00
maziggy 5551087160 y fix(ams-history): respect theme background variant in stats modal
The AMS humidity/temperature stats modal hardcoded color literals
      gated on a light/dark boolean, so it ignored the active background
      variant (neutral / warm / cool / oled / slate / forest) and the
      light-bg variants. Switched modal chrome, stat cards, and the
      recharts grid / axes / tooltip to read --bg-secondary, --bg-primary,
      --border-color, --text-primary, --text-secondary, --text-muted from
      the active theme so the modal follows mode AND background variant.
2026-06-28 12:36:40 +02:00
maziggy 964015deaf 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-28 12:36:20 +02:00
maziggy b691605509 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-28 12:36:02 +02:00
maziggy 580f42c1ec 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-28 12:35:42 +02:00
maziggy 11227f65b3 chore(deps): dompurify 3.4.10 -> 3.4.11 (GHSA-cmwh-pvxp-8882, moderate) 2026-06-28 12:35:24 +02:00
maziggy 03d092387f fix(install): docker installer tries mkdir without sudo, escalates on EACCES (#1774)
install/docker-install.sh::create_install_dir ran `mkdir -p
      "$INSTALL_PATH"` without sudo while DEFAULT_INSTALL_PATH was
      /opt/bambuddy, root-owned on every Linux distro. set -e then
      aborted the whole script before docker compose could pull the
      image — anyone running the documented `curl ... | bash` flow as
      a normal user hit this on first install.

      Fix: try the unprivileged `mkdir -p ... 2>/dev/null` first so
      --path ~/bambuddy, /srv/bambuddy and other writable targets don't
      trigger a needless password prompt, then fall back to
      `sudo mkdir -p` + `sudo chown -R "$USER:$USER"` only when the
      first attempt failed. The chown is load-bearing: without it the
      script would later try to write docker-compose.yml + .env into a
      root-owned dir as the invoking user and cascade further EACCES
      failures.

      Not changing the default path: install/update.sh and
      install/update_macos.sh both default INSTALL_DIR to /opt/bambuddy,
      and install/README.md's update flow documents the same — flipping
      the install default to ~/bambuddy without coordinating those
      would silently break self-service updates for anyone following
      the docs verbatim. The default stays /opt/bambuddy; only the
      escalation gap closes.

      set -e survives the redirected stderr because the `if !` form is
      the documented escape hatch for an expected-failure check.

      Smoke-tested writable-target, idempotent-rerun, and the
      failing-mkdir-then-sudo-fallback branches.
2026-06-28 12:34:54 +02:00
maziggy d2232e0291 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-28 12:34:22 +02:00
maziggy b118dd2687 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-28 12:34:05 +02:00
maziggy 0128869d87 fix(printers): post-#1661 cleanup — test fixtures + remove hover-card fly-in
- The Speed and AMS load/unload tests broke after the printer-card
        refactor in #1661 (icon-only Gauge button replaced the "100%" badge,
        hover-card actions replaced the kebab "Slot options" button). Add
        data-testid="speed-control" + data-testid="filament-slot" as stable
        test hooks, rewrite both files around them. Parametrize the four-mode
        speed-selection test. Update the "RUNNING hides menu" assertion to
        "Load/Unload buttons exist but are disabled" — the new UX shows
        actions on hover and disables them rather than hiding the trigger.
      - Drop `animate-in fade-in-0 zoom-in-95 duration-150` from
        FilamentHoverCard and EmptySlotHoverCard. The card briefly painted
        at the offscreen (-9999, -9999) coords during the zoom-in transition,
        reading as a fly-in from the upper-left corner. With the animation
        gone it just appears in place at its computed position.
2026-06-28 12:33:50 +02:00
maziggy 946db27537 test(printers): repair speed + AMS load/unload tests after #1661 refactor
PR #1661 swapped the visible speed badge ("100%") for an icon-only Gauge
      button and replaced the kebab "Slot options" button with hover-card
      actions inside FilamentHoverCard. The two existing test files weren't
      updated alongside the refactor and stayed broken on dev.

      - Add data-testid="speed-control" to the Gauge button and rewrite the
        Speed tests around it; assertions on the percentage text are gone
        because that text no longer renders. Parametrize the four-mode API
        call test.
      - Add data-testid="filament-slot" to FilamentHoverCard's trigger
        wrapper and rewrite the AMS load/unload tests around
        fireEvent.mouseEnter → portaled actions. Replace the "hides menu
        while RUNNING" assertion with "Load/Unload buttons exist but are
        disabled" — matches the new UX shape.
2026-06-28 12:33:31 +02:00
maziggy 6fa74be429 feat: Update printer card UI for structure and readability (#1661) 2026-06-28 12:33:05 +02:00
maziggy 18270b4a69 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-28 12:31:58 +02:00
maziggy 8283b175c0 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-28 12:31:11 +02:00
maziggy 0eed98657f fix(local-presets): optimistic remove on delete
The Slicer -> Local Profiles page kept showing a just-deleted row for
      the ~hundreds of ms it took invalidateQueries to refetch. A quick
      re-click on the same row opened a second delete-confirm modal that
      resolved to a 404 from the backend.

      Add an optimistic queryClient.setQueryData filter in deleteMutation's
      onSuccess so the row disappears the instant the DELETE returns 200.
      Existing invalidateQueries calls stay in place to reconcile any drift.

      Found while reproducing #1713 (verifying maziggy's setup against the
      reporter's). Unrelated to that investigation but caught here.
2026-06-28 12:30:49 +02:00
maziggy f7620406cb chore(frontend): vite 7 -> 8 + plugin-react 5.2
Major version bump for the frontend build:
      - vite ^7.3.2 -> ^8.0.16
      - @vitejs/plugin-react ^5.1.1 -> ^5.2.0

      Vite 8 swaps Rollup for Rolldown as the default bundler
      (Rust-backed, same plugin contract). The bump also lifts the
      transitive esbuild floor to 0.28.1, closing the last open
      advisory in the audit chain.

      vite.config.ts surface audited and unchanged:
      - defineConfig, Connect type
      - serveGcodeViewer configureServer middleware
      - server.proxy with WebSocket upgrade for /api/v1/ws
      - build.outDir / emptyOutDir / chunkSizeWarningLimit
      - resolve.alias for @
      - base: '/' regression guard from #1221

      vitest@4.1.8 already accepts vite 8 in its peer range
      (^6 || ^7 || ^8); no test-runner bump required.

      Node floor for vite 8 is ^20.19.0 || >=22.12.0; CI Node 20.x
      line satisfies this.

      Not taken: plugin-react v6 — it requires
      babel-plugin-react-compiler and @rolldown/plugin-babel as
      peers and is a separate scope.
2026-06-28 12:30:24 +02:00
maziggy 000af6830b chore(frontend): dependency bumps
Runtime:
      - dompurify 3.4.0 -> 3.4.10 (package.json floor raised from
        ^3.4.0 to ^3.4.10 so fresh installs cannot land on the
        deprecated 3.4.4 release; release notes 3.4.1 -> 3.4.10
        reviewed — the three call sites (MakerworldPage,
        ProjectDetailPage, ProjectPageModal) use string-output
        sanitisation and are unaffected by 3.4.4's widened default
        allow-list)

      Build / lint / test tooling (transitive, dev-only):
      - @babel/core 7.29.0 -> 7.29.7 (via @vitejs/plugin-react and
        eslint-plugin-react-hooks)
      - vite 7.3.2 -> 7.3.5
      - markdown-it 14.1.1 -> 14.2.0 (via @tiptap/extension-link
        -> @tiptap/pm -> prosemirror-markdown; Bambuddy never calls
        markdown-it.render directly)
      - js-yaml 4.1.1 -> 4.2.0 (via eslint)
      - form-data 4.0.5 -> 4.0.6 (via jsdom)
      - ws 8.20.1 -> 8.21.0 (via jsdom)
2026-06-28 12:29:58 +02:00
maziggy 355d08a8f6 fix(spoolbuddy): inventory search matches spool ID + storage location (#1738)
The SpoolBuddy inventory page reimplemented its filter inline and only
      matched material/subtype/brand/color_name/note, while Bambuddy's main
      inventory uses the shared filterSpoolsByQuery helper which also matches
      spool ID, slicer_filament_name, and storage_location. Delegate to the
      shared helper so both pages stay in lockstep.

      - frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx: replace
        inline filter with filterSpoolsByQuery
      - frontend/src/__tests__/pages/SpoolBuddyInventorySearch.test.ts: lock
        in ID / partial ID / pre-fix fields / parity-gain fields
2026-06-28 12:29:29 +02:00
maziggy 37d5dfe25a Housekeeping 2026-06-28 12:29:03 +02:00
maziggy 0ebd354384 . 2026-06-28 12:28:16 +02:00
maziggy 7f15088615 fix(auth/ui): sidebar accepts granular *_read tiers for archives/queue/files (#1755)
navPermissions in Layout.tsx gated three resources on the LEGACY *:read flag.
      Default Operators group is seeded with *_own only (and the migration map flips
      legacy → _own on existing groups), so non-admin users never held the legacy
      permission and the sidebar hid Archives / Queue / Files even though the
      underlying API accepted their requests. Reporter only spotted Files; same bug
      shape applied to Archives and Queue.

      Fix: navPermissions accepts Permission | Permission[]; the three affected
      resources list all three tiers. isHidden checks .some(hasPermission) for
      arrays. Permission type extended with the matching *_own / *_all variants —
      backend already shipped them, the TS type just didn't declare them.
2026-06-28 12:25:38 +02:00
maziggy 2cbbd1eed3 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-28 12:25:25 +02:00
maziggy ed1683fe3e fix(auth): preserve original URL across login + OIDC round-trip (#1750)
ProtectedRoute and PermissionRoute now pass the requested location as
      router state when redirecting to /login. LoginPage stashes it in
      sessionStorage before the OIDC provider redirect (since window.location
      kills React state) and consumes it on all three post-login navigations
      (credentials, 2FA, OIDC token exchange). Targets are sanitized to
      same-origin internal paths only — protocol-relative and /login itself
      are rejected to prevent open-redirect.

      QR labels (https://host/inventory?spool=N) now land on the scanned
      spool instead of the printer page after authentik / any OIDC SSO login.
2026-06-28 12:25:05 +02:00
maziggy 8faaeb96e0 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-28 12:24:44 +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 0d4b9d4e91 feat(settings): prominent sponsor banner on General tab
Matomo shows only 1.18% of website visitors reach /sponsors despite
  29% hitting /installation. The ask was discoverable on the marketing
  site but invisible in-app where users actually live.

  Full-width gradient banner sits above the three-column layout on the
  default landing tab and links to bambuddy.cool/sponsors.html with a
  ?from=app-settings tracking param so conversion lift is measurable
  in Matomo. Three new sponsors.* keys translated to all 11 locales.
2026-06-20 14:28:56 +02:00
maziggy f39d1397f7 Updated README 2026-06-20 13:00:18 +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 a39ed5b42e fix(ams-history): respect theme background variant in stats modal
The AMS humidity/temperature stats modal hardcoded color literals
  gated on a light/dark boolean, so it ignored the active background
  variant (neutral / warm / cool / oled / slate / forest) and the
  light-bg variants. Switched modal chrome, stat cards, and the
  recharts grid / axes / tooltip to read --bg-secondary, --bg-primary,
  --border-color, --text-primary, --text-secondary, --text-muted from
  the active theme so the modal follows mode AND background variant.
2026-06-20 10:00:13 +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 ca20342949 Post work PR #1701 2026-06-19 12:41:56 +02:00
BambuMan 5a92115546 feat(api-keys): QR code on key creation encoding server URL + key (#1677) (#1701) 2026-06-19 12:35:39 +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
maziggy 9e24d8d297 chore(deps): dompurify 3.4.10 -> 3.4.11 (GHSA-cmwh-pvxp-8882, moderate) 2026-06-19 11:49:53 +02:00
maziggy 9b5cc1b4f1 Post work PR #1516 2026-06-19 11:45:39 +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 9ca2dd08cf Updated BACKERS 2026-06-19 08:48:13 +02:00
maziggy 1773cbd629 fix(install): docker installer tries mkdir without sudo, escalates on EACCES (#1774)
install/docker-install.sh::create_install_dir ran `mkdir -p
  "$INSTALL_PATH"` without sudo while DEFAULT_INSTALL_PATH was
  /opt/bambuddy, root-owned on every Linux distro. set -e then
  aborted the whole script before docker compose could pull the
  image — anyone running the documented `curl ... | bash` flow as
  a normal user hit this on first install.

  Fix: try the unprivileged `mkdir -p ... 2>/dev/null` first so
  --path ~/bambuddy, /srv/bambuddy and other writable targets don't
  trigger a needless password prompt, then fall back to
  `sudo mkdir -p` + `sudo chown -R "$USER:$USER"` only when the
  first attempt failed. The chown is load-bearing: without it the
  script would later try to write docker-compose.yml + .env into a
  root-owned dir as the invoking user and cascade further EACCES
  failures.

  Not changing the default path: install/update.sh and
  install/update_macos.sh both default INSTALL_DIR to /opt/bambuddy,
  and install/README.md's update flow documents the same — flipping
  the install default to ~/bambuddy without coordinating those
  would silently break self-service updates for anyone following
  the docs verbatim. The default stays /opt/bambuddy; only the
  escalation gap closes.

  set -e survives the redirected stderr because the `if !` form is
  the documented escape hatch for an expected-failure check.

  Smoke-tested writable-target, idempotent-rerun, and the
  failing-mkdir-then-sudo-fallback branches.
2026-06-19 08:14:49 +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 fd5c95809f Updated README 2026-06-18 12:12:43 +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
maziggy db63e0b477 fix(printers): post-#1661 cleanup — test fixtures + remove hover-card fly-in
- The Speed and AMS load/unload tests broke after the printer-card
    refactor in #1661 (icon-only Gauge button replaced the "100%" badge,
    hover-card actions replaced the kebab "Slot options" button). Add
    data-testid="speed-control" + data-testid="filament-slot" as stable
    test hooks, rewrite both files around them. Parametrize the four-mode
    speed-selection test. Update the "RUNNING hides menu" assertion to
    "Load/Unload buttons exist but are disabled" — the new UX shows
    actions on hover and disables them rather than hiding the trigger.
  - Drop `animate-in fade-in-0 zoom-in-95 duration-150` from
    FilamentHoverCard and EmptySlotHoverCard. The card briefly painted
    at the offscreen (-9999, -9999) coords during the zoom-in transition,
    reading as a fly-in from the upper-left corner. With the animation
    gone it just appears in place at its computed position.
2026-06-18 12:02:38 +02:00