Commit Graph
2269 Commits
Author SHA1 Message Date
maziggy 7d62ff4117 Updated test_security.sh 2026-04-23 16:51:03 +02:00
maziggy bf511c54cd feat(#1008): archive auto-purge + dedicated archives:purge permission
Adds an archive counterpart to the library trash sweeper shipped in the
  previous commit. Unlike the library flow, archives are hard-deleted —
  print history is a decaying timeline, so there is no trash intermediate;
  download or favourite anything you want to keep first.

  Backend
  - New ArchivePurgeService (backend/app/services/archive_purge.py) with
    its own 15-minute scheduler loop and a 24h throttle on actual purge
    runs. Delegates every delete to the existing safety-checked
    ArchiveService.delete_archive so the 3MF, thumbnail, timelapse, source
    3MF, F3D, and photo folder all get cleaned up together with the DB
    row. Per-row session via async_session() avoids commit-per-row churn
    on any caller-passed session.
  - New /archives/purge/{preview,settings} + POST /archives/purge routes
    gated on a dedicated archives:purge permission (not archives:delete_all)
    so admins can delegate bulk-delete to a role without granting
    per-archive delete on other users' rows.
  - seed_default_groups() now backfills both library:purge and
    archives:purge on the Administrators group for upgraded installs —
    the original library:purge was added after Administrators was first
    seeded so the "create if not exists" path skipped existing DBs and
    left admins without the permission.
  - 8 new integration tests (defaults, settings roundtrip, bound
    validation, preview, manual purge, auto-purge enabled path, 24h
    throttle, disabled skip).

  Frontend
  - Settings → Archives card gains an auto-purge toggle + age input (7d
    floor, 10y ceiling, 365d default), with a save-toast on every change.
    The bulk "Purge old" button lives on the Archives page header
    (rightmost, after Upload 3MF) to match the File Manager pattern —
    configuration in Settings, one-shot action on the page.
  - New PurgeArchivesModal mirrors PurgeOldFilesModal: live preview (count
    + total size freed + sample filenames) debounced at 300ms, amber
    "hard-delete, no undo" warning.
  - Admin-only UI gates on archives:purge via the standard hasPermission
    hook; Permission TS union updated.
  - i18n blocks across all 8 locales (en/de full, other 6 English
    fallback per project convention).

  Docs
  - CHANGELOG entry under 0.2.4b1 following the existing library-trash
    entry.
  - bambuddy-wiki archiving.md gains a new "Auto-Purge" section.
  - bambuddy-website features.html gets a matching bullet.

  Verification: python -m ruff check backend/app/ clean; 25 integration
  tests pass (8 archive_purge + 17 library_trash regression); npm run
  build clean.
2026-04-23 16:47:53 +02:00
maziggy e0e597271e ● feat(#1008): library trash bin, admin bulk purge, auto-purge setting
Library files now move to a configurable-retention trash bin on delete
  instead of being hard-deleted from disk (default 30 days). Admins get a
  "Purge old" bulk action on the File Manager with a live preview, plus an
  optional auto-purge setting in Settings → File Manager that runs the same
  operation once per 24h when enabled (default off). Regular users see and
  manage their own trashed files; admins see everyone's. External (linked)
  files bypass trash since their bytes aren't under Bambuddy's control.

  - New `library:purge` permission (admin-only by default)
  - Nullable indexed `deleted_at` column on library_files; dialect-aware
    ALTER TABLE so the column actually gets added on PostgreSQL (raw
    DATETIME is SQLite-only syntax)
  - New `LibraryFile.active()` classmethod; every query site routed through
    it so trashed rows don't leak into listings, print dispatch, MakerWorld
    dedupe, or stats
  - Trash page: select-all + bulk restore/delete, per-row checkboxes, wider
    layout so datetime columns don't clip
  - Auto-purge: 24h throttle via `library_auto_purge_last_run` setting so
    the 15-minute sweeper cadence still runs the purge at most once per day
  - Save toast wired into every trash/auto-purge setting change
  - 17 new backend integration tests (service + routes + auto-purge throttle),
    8 new frontend tests, localised across all 8 UI languages
  - Wiki + website feature entries updated
2026-04-23 15:54:59 +02:00
maziggy 219af65c68 fix(#1096): warn on Spoolman HTTP/HTTPS mismatch instead of silent blank iframe
Users behind an HTTPS reverse proxy pointing the Spoolman URL at plain
  HTTP saw the Filament tab render as a blank page with only a console-
  side Mixed Content warning. Browsers block HTTP iframes inside HTTPS
  parents by design (independent of CSP; #1054's frame-src http: fix
  only helps when the parent is also HTTP). The fix for the user's
  setup is to put Spoolman behind the same reverse proxy with HTTPS.

  Bambuddy can't override the browser's mixed-content block, but it can
  stop rendering an iframe that will silently fail. When
  window.location.protocol is https: and the Spoolman URL starts with
  http://, render a warning card explaining the root cause and offering
  an "Open in new tab" fallback (standalone tabs aren't subject to
  mixed-content rules).

  Localised across all 8 UI languages.
2026-04-23 14:35:02 +02:00
maziggy 6538f723a4 fix(#730): back-fill archive.created_by_id on reprint when NULL
Reprint from Archive kept showing `created_by_id = NULL` even after the
  Direct Print / File Manager / Library attribution fixes in 0.2.4b1.

  Root cause: reprint reuses the source archive row (via
  register_expected_print → _expected_prints lookup) to avoid duplicate
  archives. When the source was auto-created from a printer-initiated
  print, its created_by_id was NULL — and reprint never touched it.
  Print Log correctly attributed the reprinter (set_current_print_user
  → _print_user_info at print-complete), but the Statistics per-user
  filter reads archive.created_by_id and stayed unassigned forever.

  Fix in main.py's print-complete handler: when the archive's
  created_by_id is NULL and a print-session user is known, back-fill
  from _print_user_info. Never overwrites existing attribution — the
  original uploader keeps ownership; only NULLs are filled.

  Already-completed archives stay NULL (no retroactive rewrite). Next
  print after deploy credits the current user on any NULL archive.
2026-04-23 14:24:51 +02:00
maziggy 4aa567f495 Housekeeping 2026-04-23 14:13:37 +02:00
MartinNYHC 5da403ba0c Feature/makerworld (#1099)
* feat(makerworld): URL-paste import and print for MakerWorld models

  Add a dedicated /makerworld sidebar page where users paste a MakerWorld
  model URL and get the full plate list + one-click "Import to Library" or
  "Print Now". Closes the workflow gap that kept LAN-only users on the
  Bambu Handy app solely for MakerWorld download-and-send.

  The authenticated tier reuses the existing Bambu Cloud token that
  Bambuddy already stores for firmware checks and slicer settings --
  MakerWorld shares the same auth backend, so the same JWT works there.
  No separate OAuth flow, no companion browser extension, no credential
  hijack. Anonymous users can still paste a URL and see model metadata;
  the 3MF download itself requires the Cloud login.

  Print Now hands off to the existing PrintModal (plate picker + AMS
  mapping + dispatch) so multi-filament models work via the same code
  path as library-file prints. Imported 3MFs are stored through a new
  shared save_3mf_bytes_to_library() helper so the multipart upload
  route and the MakerWorld import route don't duplicate 3MF parsing +
  thumbnail extraction logic.

  LibraryFile gains indexed source_type + source_url columns. Re-pasting
  a URL for a model already in the library returns the existing row
  instead of re-downloading -- dedupe is by canonicalised URL, not SHA256,
  because MakerWorld's download URLs are signed and change per request.

  Thumbnail proxy (/makerworld/thumbnail) hot-links through the backend
  instead of directly to makerworld.bblmw.com -- the SPA's img-src CSP
  stays strict and users' IPs don't hit MakerWorld's CDN logs. The
  endpoint is intentionally unauthenticated since <img> tags can't carry
  a Bearer token; SSRF-guarded by a CDN host allowlist so it can't be
  used as a generic proxy.

  Search and browse-catalogue are explicitly out of scope. The public
  design/search endpoint returns empty results from server-originated
  requests (likely needs csrf/session state reproducible only from a
  real browser), and the __NEXT_DATA__ HTML fallback is blocked by
  Cloudflare. URL-paste covers the realistic discovery pattern (Reddit /
  YouTube / shared links).

  Headers match kloshi-io/makerworld-api-reverse's production-tested set
  (User-Agent: 3d-printing-service/1.0, x-bbl-* client identifiers,
  Referer). The /instance/{id}/f3mf call includes ?type=download which
  community userscripts use to signal legitimate download intent. 418
  responses (MakerWorld's CAPTCHA gate) retry once with backoff and then
  surface a clear actionable error with an "Open on MakerWorld" fallback
  link; we never try to evade bot detection.

  Permissions: new makerworld:view (browse metadata, view thumbnails) and
  makerworld:import (save 3MFs to library). Administrators and Operators
  get both; Viewers get view-only. Migration grants these to existing
  groups based on whether they already have library:upload / library:read.

  Disclaimer in the UI and wiki page mirrors kloshi's framing: not
  affiliated with or endorsed by MakerWorld or Bambu Lab, interoperability
  only, not intended to circumvent access controls.

  Tests: 30 backend (service + routes) + 4 frontend. Full backend suite
  (1931 tests) clean. Frontend build clean.

* feat(makerworld): ship working URL-paste import via api.bambulab.com iot-service

  The MakerWorld integration shipped in 0.2.4b1 dev was broken for most
  public models: the makerworld.com/design-service path returns "Please
  log in to download models" even with a valid Bambu Cloud bearer,
  because it's cookie-gated behind Cloudflare. Published reverse-
  engineering projects work around this by pasting browser cookies; we
  route around it entirely by using the api.bambulab.com/iot-service
  endpoint (documented by Pr0zak/YASTL#51), which accepts the same
  bearer Bambuddy already has and returns a presigned S3 URL.

  Working flow:
    GET api.bambulab.com/v1/design-service/design/{id}  → metadata
    GET api.bambulab.com/v1/iot-service/api/user/profile/{pid}?model_id=<str>
         Authorization: Bearer {cloud_token}             → signed S3 URL
    urllib.request (no redirects, no query re-encoding)  → bytes

  Notes on each step:
    - The model_id query param is the alphanumeric string from the
      design response (e.g. US2bb73b106683e5), NOT the integer designId
      from the /models/{N} URL. The import route fetches design metadata
      first to get it.
    - S3 presigned URLs MUST be fetched with urllib (not httpx/curl_cffi)
      because the signature is computed over exact query-string bytes;
      any normalising encoder breaks it with SignatureDoesNotMatch 400s
      (YASTL#52 hit the same issue). Wrapped in a no-redirect opener so
      the .amazonaws.com host allowlist guarantee isn't bypassed by a
      302 elsewhere.
    - The canonical source_url now includes profile_id so different
      plates of the same model get distinct library entries. Older rows
      from dev builds keep the model-level URL; the resolve endpoint's
      "already imported" check LIKEs both shapes.

  UI rebuild:
    - Per-plate Save + Save & Slice in Bambu Studio / OrcaSlicer (the
      plate is unsliced source, so "Print Now" was misleading and is
      replaced by an explicit slicer hand-off).
    - Import all plates with sequential progress.
    - Folder picker (default: auto-created top-level "MakerWorld"
      folder, created on first import, folder tree invalidated so
      File Manager shows it immediately).
    - Image gallery per plate with keyboard-navigable lightbox.
    - Recent imports sidebar (sticky on lg+, vertical list with
      jump-to-library / slicer / open-on-makerworld icons).
    - Inline follow-up actions on imported plate rows so the user
      doesn't scroll back to a top-of-page card.
    - Per-plate delete via the standard ConfirmModal (no window.confirm).
    - Elapsed-time + phase label during import so the 10-30s synchronous
      POST doesn't feel frozen.
    - URL-change detection drops the preview when the pasted URL
      diverges from the resolved one.

  Security hardening (found in review):
    - DOMPurify.sanitize on the MakerWorld HTML summary before
      dangerouslySetInnerHTML (user-authored content).
    - <img> tags in that HTML routed through the thumbnail proxy so
      the SPA's img-src 'self' data: blob: CSP isn't widened.
    - /makerworld/thumbnail uses follow_redirects=False (the host
      allowlist only covers the initial URL).
    - 3MF CDN fetch strips the bearer (signed URL is the credential).
    - S3 fetch uses a no-op HTTPRedirectHandler for the same reason.
    - Upstream filename is os.path.basename'd before persisting.

  Tests: 46 backend service unit tests, 19 route tests, 12 frontend
  tests — all passing. All user-facing strings localised across the
  8 UI languages.

* - frontend/src/App.tsx — removed the 3 stale <AdminRoute> lines (kept the 3 <PermissionRoute> equivalents). TSC + Vite both clean.
  - backend/tests/integration/test_auth_api.py — added # pragma: allowlist secret + # noqa: S106 on the test fixture line that GitGuardian flagged.
2026-04-23 14:10:14 +02:00
maziggy 76b997fc8a fix(slicer): encode file URL in protocol-handler scheme on Windows/Linux
"Open in Slicer" emitted `orcaslicer://open?file=<URL>` and
  `bambustudio://open?file=<URL>` by plain string concatenation, relying
  on a stale comment that claimed the browser preserves URLs in the query
  string. That ignores the slicer's own `url_decode()` on the received
  query (BS post_init → url_decode + split_str; OrcaSlicer Downloader
  regex + url_decode), so any already-percent-encoded character — most
  commonly `%20` from filenames with spaces — decoded to a literal space
  and the slicer's subsequent HTTP GET returned 0 bytes or 404.

  All three URL forms now use `encodeURIComponent()` (matching what the
  macOS `bambustudioopen://` branch was already doing, which is why the
  bug didn't surface on macOS). Corrected the file-level comment to
  document the actual invariant.

  Regression test in slicer.test.ts feeds the exact issue reproduction
  URL and asserts `%2520` appears in the generated href.
2026-04-23 10:03:44 +02:00
maziggy 62f2e616a0 Changed CI 2026-04-23 08:57:15 +02:00
maziggy fc116f2f82 Removed unused i18next-http-backend 2026-04-23 08:51:12 +02:00
maziggy 9a38414be9 Added gitleaks 2026-04-22 20:21:33 +02:00
maziggy d52b91ca5a Added gitleaks 2026-04-22 20:18:13 +02:00
maziggy 6874fddb65 Added gitleaks 2026-04-22 20:12:15 +02:00
maziggy ffec267df1 Updated requirements-dev.txt 2026-04-22 19:44:59 +02:00
maziggy 1a31f84aaf Housekeeping 2026-04-22 19:19:58 +02:00
maziggy cecdf8f5a7 feat(auth): permission-delegated Settings + Group editor routes; fix group-edit cache stale-read (#1083)
Three intertwined changes, split by intent:

  1. Swap AdminRoute for PermissionRoute on /settings, /groups/new, and
     /groups/:id/edit. Admins retain full access; non-admin users whose
     group holds settings:read / groups:create / groups:update can now
     enter the respective pages instead of being silently redirected to
     the dashboard. SettingsPage's individual tabs and cards keep their
     existing per-action permission checks, so tabs a delegated user can't
     use stay hidden or disabled. AdminRoute had no other callers and is
     removed.

  2. Fix #1083: editing a custom group's permissions appeared to revert
     on reopen. The backend PATCH was persisting correctly — four new
     integration tests in test_groups_api.py (including a direct DB read
     after PATCH) confirm persistence, empty-list clear, preserve-on-
     absent, and 400 on bogus permission. The actual bug was a stale
     ['group', id] React Query cache: onSuccess invalidated ['groups']
     but not the detail key, so the 60s global staleTime served the pre-
     update body on re-mount. onSuccess now primes ['group', id] with the
     PATCH response body (invalidation is not enough — it races with the
     refetch). Frontend regression test added.

  3. Delegated users with settings:read but not settings:update no longer
     get an infinite loop of failed-save toasts on Settings. The debounced
     auto-save effect fires PATCH /settings whenever localSettings diverges
     from the server snapshot; without a permission gate this produced an
     endless 403 → toast → re-render → effect → 403 loop. Three gates now:
     the updateSetting callback short-circuits with a single toast before
     localSettings diverges, the effect safety-nets the same check in case
     any call site bypasses updateSetting, and the language <select> (the
     only direct api.updateSettings bypass in the file) now routes through
     updateMutation with the same guard. New settings.toast.noPermissionUpdate
     key translated in all 8 locales.

  Scoping note: an earlier iteration of change #3 included a
  localSettings rollback inside updateMutation.onError — removed in
  review because it would have discarded in-progress admin typing on
  any transient network/server error. The three up-front guards make
  the rollback unnecessary for the permission case (mutation never
  fires), and preserving typed-in values on transient failures is the
  right call for admins.
2026-04-22 19:10:18 +02:00
maziggy 991111327f fix(auth): setup 422'd on re-enable when admin user already exists
The SetupRequest Pydantic schema enforced password complexity unconditionally,
  but the route ignores admin_password entirely when an admin user already
  exists (the common case for re-enabling auth after it was disabled, or for
  LDAP deployments where the local admin is a placeholder). A legitimate
  existing password that predated the complexity rule — or the placeholder the
  form sends in LDAP mode — hit the Pydantic validator before the route body
  could decide it wasn't needed, surfacing as:

      422 Value error, Password must contain at least one special character

  Move the complexity check out of the schema and into the route body, scoped
  to the branch that actually creates a new local admin. Re-enabling auth with
  an existing admin now accepts whatever is in the field; first-time setup
  still rejects weak passwords with a clear 400 including the specific rule
  that was violated.

  Regression coverage in test_auth_api.py::TestAuthSetupAPI:
  - test_setup_weak_password_rejected_when_creating_new_admin — fresh setup
    with "NoSpecial1" → 400, "special character" in detail
  - test_setup_reenable_with_existing_admin_ignores_password — seeds an admin,
    POSTs /setup with a complexity-failing password → 200, admin_created=false
2026-04-22 18:32:29 +02:00
maziggy 758ef0057d Updated README 2026-04-22 17:49:58 +02:00
maziggy b478ff882a fix(queue): prevent duplicate dispatch and stale progress on batch prints
Two related queue issues surfaced when scheduling an ASAP print with
  quantity > 1 on an H2D:

  1. Double-dispatch — both items in the batch ended up in 'printing'
     status on the same printer, logged as "BUG: Multiple queue items in
     'printing' status for printer N". The scheduler seeded its busy
     set empty each tick and relied on _is_printer_idle() reading live
     MQTT state, but H2D / P1 series lag several seconds between the
     print command and IDLE → RUNNING, so the next check_queue() tick
     saw IDLE and dispatched the second batch item onto the already-
     running printer. check_queue() now seeds busy_printers with every
     printer_id that has a row in 'printing' status before iterating,
     so any printer with an outstanding dispatched job is excluded
     regardless of what MQTT currently reports.

  2. Progress bar flashed 100% — immediately after dispatch the queue
     item's per-row progress bar showed the prior print's final mc_percent
     for a few seconds, then snapped back to 0% when the new print
     started ticking. QueuePage.tsx now gates progress / remaining_time /
     layer fields on status.state being RUNNING or PAUSE; in any other
     state (FINISH from the prior print, IDLE, PREPARE while heating)
     the bar renders at 0% with no stale ETA or layer count.

  Regression coverage added in test_phantom_print_hardening.py
  (TestBusyPrinterSeedingFromPrintingItems, 3 tests): seeding query
  returns only printers with 'printing' rows, empty when none exist,
  and end-to-end check_queue() does not call _start_print for a pending
  item whose printer already has a 'printing' row even when
  _is_printer_idle() is forced True.
2026-04-22 17:48:53 +02:00
maziggy 3c7625f264 Updated CHANGELOG 2026-04-22 14:15:55 +02:00
maziggy c44b62195a refactor(gcode-viewer): archive-scoped previews, bed from capabilities, plate picker
Reshapes the embedded PrettyGCode viewer (landed in #963) into a focused
  archive-preview tool, matching Bambuddy's data model instead of the
  OctoPrint-style "connected-printer + library file picker" flow it shipped
  with. Reached only from the Archives page 3D-preview button; URL
  /gcode-viewer?archive=<id>[&plate=<N>].

  Backend:
  - /archives/{id}/gcode accepts ?plate=N and resolves the filename by
    parsing the suffix as int, so zero-padded names like plate_01.gcode
    are found when the plates endpoint reports index 1.
  - /archives/{id}/plates gains top-level has_gcode: bool. Source-only
    3MFs (PNG/JSON fallback path) surface the flag so the frontend can
    skip the picker instead of sending the user into a dead viewer.
  - printer_state_to_dict injects name + model into every WS snapshot so
    consumers render proper labels on the initial tick without racing a
    separate /printers fetch.
  - /gcode-viewer (no trailing slash) dropped from the backend so reloads
    fall through to the SPA catch-all and keep the layout shell; only
    /gcode-viewer/ (trailing slash) and /gcode-viewer/<path> remain for
    the iframe + static assets.

  Frontend:
  - PlatePickerModal shown only for multi-plate archives with sliced
    gcode, grid layout with thumbnails matching the Re-print modal.
  - Source-only archives show a noGcode toast instead of the empty
    viewer.
  - ArchivesPage navigate path swapped to /gcode-viewer?archive=<id> with
    no trailing slash; GCodeViewerPage iframe forwards
    window.location.search so the archive reference survives both the
    initial navigate and a full-page reload.
  - Viewer iframe's auth path: fetch intercept injects Bearer; a 401
    redirects to / so the SPA handles login.

  Viewer adapter:
  - Stripped the printer selector, WebSocket subscription, library file
    picker, tryAutoLoadPrintingFile, BAMBU_BED_SIZES, and updatePrinter-
    Selector. The viewer no longer observes live printer state.
  - Bed size derived from /archives/{id}/capabilities.build_volume
    (extracted from the 3MF's printable_area/printable_height), so H2D,
    H-family, and any future printer render on the correct bed without
    a hardcoded map.
  - loadArchiveById accepts a plate param; fetch intercept rewrites
    __bambuddy_archive_<id>[_plate<N>] to /archives/<id>/gcode[?plate=N].

  Nav + locale cleanup:
  - Sidebar "GCode Viewer" nav entry removed (viewer is archive-scoped
    now, not a destination page).
  - 32 orphaned gcodeViewer locale keys deleted across all 8 locales.
  - platePicker.{title, hint, plateLabel, objectCount, noGcode} keys
    added in all 8 locales.

  ArchivesPage: the now-unreachable ModelViewerModal render paths + its
  showViewer state removed. ModelViewerModal itself stays — File Manager
  still uses it for library file previews (plate picker + .3mf 3D model).

  pre-commit:
  - gcode_viewer/ excluded from trailing-whitespace + end-of-file-fixer
    so vendored third-party JS libs don't drift away from upstream.

  Incidental sweeps picked up by pre-commit and kept (unrelated but
  benign):
  - NotificationsPage.tsx: single trailing-whitespace line removed.
  - spoolbuddy/scripts/pn5180_diag.py: dead `import gpiod` dropped —
    the pn5180 driver module imported at line 27 does its own
    `import gpiod` and `gpiod.Chip()` calls, so the diag script's
    top-level import was never referenced.

  Tests:
  - 6 new cases in test_gcode_viewer.py for the backend plate / has_gcode
    behaviour (plate=N resolution, zero-padded filenames, missing-plate
    404, no-plate fallback, plate=0 rejection, has_gcode true/false).
  - 3 new cases in test_printer_manager.py for name/model WS injection.
  - PlatePickerModal.test.tsx — 6 frontend cases covering render,
    plate-name composition, onSelect payload, backdrop close, and
    thumbnail fallback.
2026-04-22 13:03:09 +02:00
Nathen Fredrick 3adce435ee feat: add embedded GCode viewer (#963)
* feat: add embedded GCode viewer

Adds PrettyGCode as a built-in GCode visualiser embedded directly in the
Bambuddy layout, so users can preview and inspect GCode files without
leaving the dashboard.
2026-04-22 11:14:42 +02:00
maziggy 5215ac68e9 ● refactor(printers): remove redundant in-widget clear-plate button
In expanded view, PrinterQueueWidget rendered its own "Clear Plate & Start
  Next" button inside a yellow-bordered card whenever the plate-clear gate
  was up and an auto-dispatch item was queued. PR #939 added the card-level
  "Mark plate as cleared" button that already covers that state — and every
  other state (staged-only queue, empty queue, etc.) — so both buttons hit
  the same /clear-plate endpoint with identical optimistic-update semantics.
  Two controls, one action, visible together in one specific state.

  Remove the widget's button and its entire needsClearPlate render branch.
  The widget becomes a passive "Next in queue" preview linking to /queue;
  the card-level button remains the single plate-clear entry point.

  Also drop:
  - now-dead awaitingPlateClear / requirePlateClear / printerState props
    from PrinterQueueWidgetProps and the matching call site
  - orphaned queue.clearPlate / queue.plateReady translations from all eight
    locale files (queue.clearPlateSuccess stays — used by the card button's
    success toast)
  - PrinterQueueWidgetClearPlate.test.tsx (654 lines) — every test asserted
    the behaviour of the now-gone button; PrinterQueueWidget.test.tsx still
    covers the passive-link path

  Deliberately *not* changed: plate-status pill stays inside the Status box
  (lines 2664/2671/2736/2783 of PrintersPage.tsx). Compact-view (Size S)
  pill and icon-only clear button at :2664/:2671/:2673 untouched.
2026-04-22 09:22:24 +02:00
maziggy 0918907dab fix(scheduler): watchdog falsely reverts slow H2D dispatches, causing reprints (#1078)
_watchdog_print_start reverted queue items to "pending" at 45 s if
  gcode_state hadn't changed, assuming the MQTT project_file was swallowed
  by a half-broken session (#887/#967). H2D Pro firmware (01.01.00.00)
  routinely keeps state=FINISH for 48-55 s after actually accepting the
  command before transitioning to PREPARE. The watchdog reverted items
  the printer had already started physically printing; the archive updated
  normally via _active_prints, but the queue item was now "pending" again,
  and the next scheduler tick after plate-clear re-dispatched the same
  item as if it had never run. With one item left in the queue that looked
  like a reprint of the just-finished job; with multiple items the
  symptom was masked by item N+1 getting dispatched during the race.

  Add a second "command landed" signal: subtask_id advancing past the
  pre-dispatch value. Bambuddy already mints a unique submission_id per
  project_file publish (#1042) and the printer echoes it back on the next
  push_status as soon as it starts processing the command - well before
  gcode_state transitions on slow-transition models. _start_print now
  captures pre_subtask_id alongside pre_state and passes both to the
  watchdog, which exits early on either a state change or a subtask_id
  advance.

  Raise default timeout 45 s → 90 s as belt-and-braces for printers that
  neither flip state nor echo subtask_id inside the polling window.
  Genuinely half-broken sessions (both signals unchanged across the full
  90 s) still revert + force-reconnect exactly as before.

  Transient subtask_id=None during reconnect is not mis-detected as a
  change. pre_subtask_id=None falls back to state-only checking so the
  fix is safe for printers that haven't reported a subtask_id yet.

  New test_scheduler_watchdog.py pins the eight behaviours that matter:
  pickup via state change; pickup via subtask_id change with state still
  FINISH (the exact #1078 case); revert when neither signal changes;
  default timeout is 90 s; pre_subtask_id=None state-only fallback;
  current subtask_id=None not treated as change; printer disconnect
  mid-watchdog leaves DB untouched; item that already moved on is not
  clobbered.
2026-04-22 08:38:50 +02:00
maziggy 4e86e8cb16 fix(printers): Clear-Plate button delayed 30s–5min after print completes (#939 follow-up)
PR #939 added the awaiting_plate_clear gate but stored it on
  PrinterManager, not on PrinterState. printer_state_to_dict() — which
  builds every WebSocket printer_status payload — never emitted the flag,
  so the frontend's WS merge preserved the stale false value. The only
  path that surfaced true was the 30s HTTP fallback poll, and incoming WS
  ticks kept bumping React Query's dataUpdatedAt, pushing the refetch out
  further on chatty printers.

  Emit awaiting_plate_clear from printer_state_to_dict by reading
  printer_manager.is_awaiting_plate_clear(printer_id) directly; returns
  False when no id is passed. No frontend change needed — the existing WS
  merge carries the flag end-to-end and the button now appears the instant
  the printer transitions to FINISH.

  Regression tests assert the WS dict always contains the key and surfaces
  True when the manager has the flag set for that printer_id.

  Affects every printer (A1/H2D/X1C) equally — transport-agnostic path.
2026-04-21 18:10:02 +02:00
maziggy 597d961b0c fix(ui): Change Password modal leaked password affordance onto Printers search field
Opening the sidebar's Change Password modal while on the Printers page
  caused the "Search printers" input to render as a masked password field
  and stay that way after closing the modal.

  Root cause: the modal had three type=password inputs but no accompanying
  username anchor, so password-manager extensions (1Password, Bitwarden,
  browser built-ins) hunted the DOM for a matching text input and latched
  onto the unlabelled Printers-page search bar.

  - Layout.tsx: add hidden autocomplete=username anchor at the top of the
    Change Password modal form. Also ensures saved new passwords are
    correctly keyed to the logged-in user.
  - PrintersPage.tsx: harden the search input with type=search,
    name=printer-search, autoComplete=off, data-1p-ignore, data-lpignore
    so heuristic autofill skips it regardless.
2026-04-21 17:22:34 +02:00
maziggy 28f80f948a fix(ams): keep PFUS preset id when cloud filament_id is null (#1053)
Bambu Cloud returns filament_id=null for user presets that only override
  fields of a generic base (e.g. "Sting3D ABS" inheriting from
  "Generic ABS @BBL H2D"). ConfigureAmsSlotModal fell back to
  convertToTrayInfoIdx(base_id), which strips "S" and the version suffix
  from "GFSB99_07" to "GFB99" — Generic ABS's filament_id. The printer
  accepted and echoed back GFB99, so OrcaSlicer / BambuStudio Sync
  Filaments resolved the slot to "Generic ABS" and the custom preset
  never appeared on the printer LCD.

  The preceding default already set tray_info_idx to the PFUS*/PFSP*
  setting_id unchanged, and the rest of the stack round-trips that
  format (configure_ams_slot, inventory Assign Spool, and print
  scheduler slot-matching on P* short-form IDs). The base_id branch
  overwrote the correct default.

  Remove the base_id fallback. When cloud detail returns a distinct
  filament_id we still prefer it; otherwise the setting_id default
  stands. BambuStudio Sync now resolves the custom preset cleanly.
  OrcaSlicer falls back to the inherited generic because OrcaSlicer
  user-preset JSONs don't carry a filament_id field — that is an
  OrcaSlicer limitation and behaviour is strictly not worse than before.

  Regression tests (frontend):
    - filament_id=null keeps PFUS* as tray_info_idx
    - concrete filament_id wins over the default
    - GFS* path skips the cloud-detail fetch entirely
    - fetch failure degrades gracefully to the PFUS* default

  Regression tests (backend):
    - test_configure_pfus_preserves_setting_id_pair: HT slot endpoint
      forwards both tray_info_idx=PFUS… and setting_id=PFUS… untouched

  Thanks to @mrnoisytiger for the browser-console / network / backend-log
  data that isolated the fallback path and the OrcaSlicer preset JSON
  that showed the missing filament_id field.
2026-04-21 16:26:56 +02:00
maziggy bf5135cb12 fix(inventory): malformed rgba no longer bricks the Filaments page (#1055)
A single legacy spool with a 7-char rgba ('FFFFFFF', missing one F)
  caused GET /api/v1/inventory/spools to 500 with a pydantic
  ResponseValidationError, leaving the reporter with a blank Filaments
  page and "Add Spool" silently failing. Root cause spans three layers:

  1. Write path: SpoolUpdate.rgba had no pattern constraint (only
     SpoolCreate did), so PATCH could plant malformed values in the DB.
  2. Frontend: ColorSection hex input's `val.length <= 6 ? 'FF' : ''`
     emitted 7-char rgba for 5-char input (XXXXX + FF = 7) and for
     7-char typed input (no alpha appended).
  3. Read path: SpoolResponse inherited the write-side pattern, so a
     single bad row 500'd the entire list endpoint instead of being
     tolerated through serialize.

  SpoolUpdate.rgba now carries the same ^[0-9A-Fa-f]{8}$ pattern as
  SpoolCreate. The hex input emits a fully-formed 8-char RRGGBBAA on
  every keystroke — 8-char paste passes through, 7-char drops the
  stray, shorter input pads RGB with '0' and appends FF alpha.
  SpoolResponse.rgba is now Optional[str] with no pattern — write-side
  validation is the right place for format rules; responses must
  tolerate historical rows.

  Tests: 16 schema tests (SpoolCreate/Update reject, SpoolResponse
  tolerate), 7 frontend tests covering every input length 0–8 plus
  non-hex strip. A user who already has a bad row in their DB now sees
  it render with a default color instead of having to hand-edit SQLite.
2026-04-21 14:40:03 +02:00
maziggy 1682b6956f fix(dispatch): clean up transient library upload from Direct-Print flow (#730)
The "Print" button on a printer card (and drag-drop-onto-card) used
  FileUploadModal to persist the file as a LibraryFile, then dispatched
  through POST /library/files/{id}/print. The LibraryFile row + disk file
  were left behind after every one-off print, polluting File Manager with
  entries the user never asked to save.

  FilePrintRequest.cleanup_library_after_dispatch (default False) opts
  into post-dispatch cleanup. When set, _run_print_library_file stages
  db.delete(lib_file) in the same transaction as archive_print so a
  mid-flight FTP / start_print failure rolls both back cleanly, commits
  together, then unlinks the library disk file + thumbnail after commit
  succeeds. External library files (is_external=True) are never touched.

  Only the Printers-page Direct-Print PrintModal sets the flag. Every
  other api.printLibraryFile caller (File Manager Print, Project Detail
  Print) leaves it unset — their entries are there by user intent.

  Also moves formatPrintName out of PrintersPage.tsx into a new
  utils/printName.ts module — fa1c46d9 (#881) exported it inline so its
  test could import it, tripping react-refresh/only-export-components.
2026-04-21 14:10:04 +02:00
maziggy 276a1db3ef fix(dispatch): forward authenticated user through library-print path (#730 follow-up)
The 0.2.3.1 fix (f03d0c4c) added current_user to the library print
  endpoint and plumbed it into the dispatch job object, but the job
  runner never read it back out. _run_print_library_file called
  ArchiveService.archive_print() without created_by_id and never called
  set_current_print_user, so archives created by the printer-card "Print"
  button, File Manager prints, and Library prints all landed with
  created_by_id=NULL and were invisible to the per-user statistics filter.
  The post-print user-targeted notification also had no recipient.

  Forward job.requested_by_user_id to archive_print() at creation time
  and register the current-print user after start_print succeeds,
  matching _run_reprint_archive (lines 686-691).

  Reprint-from-Archive still shows NULL for archives whose original
  created_by_id was NULL — the reprint path reuses the source row as-is
  and only refreshes started_at. Tracked as a separate follow-up.
2026-04-21 13:41:06 +02:00
maziggy 64dc3b9941 Updated .gitignore 2026-04-21 13:24:12 +02:00
maziggy fe21e823ba Updated UPDATING.md 2026-04-21 11:35:17 +02:00
maziggy fa1c46d9a5 feat(printers): show plate name on card for multi-plate active prints (#881)
When two printers were running different plates of the same multi-plate
  3MF, the Printers page cards displayed the same file name on both and
  there was no way to tell them apart. The Queue view already had this
  information by cross-referencing the archive's plate list; the card
  didn't have the linkage.

  Expose `current_archive_id` (resolved by matching the MQTT `subtask_id`
  against `PrintArchive.subtask_id` — the bridge introduced in #972 for
  restart-resume) and `current_plate_id` (parsed from `gcode_file` by a
  new shared `parse_plate_id` helper) on the status endpoint. The helper
  is also called from the WebSocket push path so plate transitions
  reflect within 100 ms instead of waiting 30 s for the next REST poll;
  the archive id itself stays REST-only since it's stable for the life
  of a print and shouldn't make the push path touch the DB.

  The card fetches plate metadata via the same `api.getArchivePlates()`
  call QueuePage uses — shared React Query cache keeps it cheap across
  polls — and renders the actual plate name (or a "Plate N" fallback)
  only when `is_multi_plate` is true. Single-plate prints stay clean.
  Falls back to the previous `plate_N.gcode` regex path when there's no
  archive linkage (e.g. prints started directly from the printer LCD).

  Tests cover the plate-id extraction across Bambu Studio path shapes
  (backend parse_plate_id, printer_state_to_dict wiring) and the label
  override precedence in formatPrintName (frontend).
2026-04-21 09:46:42 +02:00
maziggy 07ef042729 fix(csp): allow http: iframes so Spoolman loads on HTTP LAN hosts (#1054)
The strict CSP shipped in 0.2.3b4 / 0.2.3.1 whitelisted only `https:`
  for `frame-src`, so the Filament tab's Spoolman iframe was blocked
  on the typical self-host setup where Spoolman runs on plain HTTP on
  a LAN. Reporter saw a blank Filament page with a brief Spoolman
  flash on reload and a browser-console CSP violation pointing at
  `http://<host>:7912/spool`.

  Allow `http:` as well, matching the `connect-src 'self' ws: wss:`
  pattern already used for WebSockets. `frame-ancestors 'none'` still
  prevents Bambuddy itself from being framed cross-origin, which is
  the protection that actually matters for clickjacking defense.
2026-04-21 09:18:25 +02:00
maziggy 87a5aa36e9 fix(ams): HT slot shows "Generic" after configuring custom preset (#1053)
After configuring an AMS-HT slot with a custom cloud preset, the slot
  card and Configure modal kept showing "Generic PLA" even though the
  printer and slicer had the correct preset. The `/slot-presets` response
  keyed HT entries at `ams_id * 4 + tray_id = 512`, but frontend lookups
  used `ams_id` directly (128 on PrintersPage via getGlobalTrayId, 64 on
  SpoolBuddy via a one-off formula). All three agreed for regular AMS, so
  the mismatch only surfaced on HT — the saved preset never reached the
  UI and the render fell through to `tray.tray_type`.

  Backend now keys via a helper that mirrors frontend `getGlobalTrayId`.
  SpoolBuddy's AMS page switches to the shared helper. Regression test
  covers regular, HT, and external slot keys.
2026-04-20 17:32:34 +02:00
maziggy a4c7d6d5cf Updated issue template 2026-04-20 15:10:59 +02:00
maziggy 1de4e409b0 fix(printer): suppress "not homed" re-prompt after Auto Home (#1052 follow-up)
The bed-jog "not homed" warning modal was gated on a session-scoped
  "warned" flag set only by the "Move anyway" button. Clicking "Auto
  Home" sent the G28 and closed the modal but never set the flag, so the
  next jog click in the same session re-prompted — even though the
  printer was now homed.

  The homeAxes mutation's onSuccess handler now flips the same
  `bambuddy.bedJog.warned.<printerId>` sessionStorage flag. The warning
  still fires once per printer per session (intended safety guard,
  cleared on restart), but not repeatedly after a successful auto-home.
2026-04-20 13:06:29 +02:00
maziggy 7026a6de77 fix(printer): bed-jog "Home Z" could crash bed into toolhead on H2C/H2D/H2S/X1 (#1052)
Critical safety fix. The bed-jog dialog's "Home Z" button sent a bare
  `G28 Z` over gcode_line. On Bambu printers where the Z endstop is at
  the top (bed moves UP into it — H2C, H2D, H2S, X1 family), `G28 Z`
  skips the toolhead-park step that a full `G28` runs first, so the bed
  rises at full speed with nothing getting out of the way. The reporter
  only escaped damage because the toolhead happened to be parked on the
  purge chute.

  The /printers/{id}/home-axes endpoint and BambuClient.home_axes() now
  always send bare `G28` regardless of the axes argument, triggering the
  firmware's safe multi-step routine (park toolhead → home XY → home Z).
  The axes argument is kept for API compat but ignored; invalid values
  still return 400.

  Frontend retitles the button "Auto Home" and updates the dialog copy
  in all 7 locales so users aren't surprised when X/Y motion happens
  before Z. Parameterized regression test asserts z/xy/all all produce
  bare G28.
2026-04-20 12:56:31 +02:00
maziggy bc895dff6a ● fix(ams): restore Configure/Assign actions on reset slots + relax Assign Spool filtering (#1047)
Three related fixes reported together:

  (1) After resetting an AMS slot, the printer card showed "Empty Slot"
  with no Configure or Assign Spool actions while SpoolBuddy's AMS page
  still let the user re-configure the same slot. Commit c9efa4b8 (#784)
  added a `tray?.state === 10` gate to the EmptySlotHoverCard actions,
  intended to hide them on physically-empty slots (state=9). In practice
  firmware often reports state=9 (or omits state entirely) after a
  user-initiated reset even when a spool is still present, so the gate
  hit the wrong case. The gate was redundant anyway — EmptySlotHoverCard
  only renders when tray_type is empty — so it's removed at both the
  standard-AMS and AMS-HT render paths.

  (2) After configuring a slot with a Generic profile, the Assign Spool
  modal hid manually-added inventory spools even when material matched,
  unless the user flipped "Show all spools". The filter required exact
  slicer_filament_name equality, which manually-added spools don't
  populate. Filter now prefers exact slicer-profile match when both
  sides have one, and falls back to partial material match in either
  direction (so a "PLA" spool shows up for a "PLA Basic" slot).

  (3) On assign, the mismatch dialog fired on every Generic spool
  because Bambu Studio / OrcaSlicer profile names carry an @printer
  nozzle (variant) qualifier while the tray stores the bare base name.
  Both the filter and checkProfileMatch now strip everything from @
  onward before comparing.

  Adds 3 regression tests covering each path.
2026-04-20 12:45:49 +02:00
maziggy 1c076850ad Updated CHANGELOG 2026-04-20 11:56:33 +02:00
maziggy c894899baf ● chore(i18n): collapse informational drift to summary counts
The parity gate expansion in 8f9eb0d4 started printing the full
  missing-key and placeholder-mismatch lists for every informational
  locale on every test run, which was noisy given CI only cares about
  strict locales. Collapse info reports to one line per category
  (`fr: missing keys vs en: 74`) and keep the full lists available via
  VERBOSE_INFO=1 for when someone is actually catching up a locale.
2026-04-20 11:55:18 +02:00
maziggy e5dfb96351 fix(skip-objects): enlarged plate preview fails to load on auth-enabled instances (#1046)
The mini thumbnail wrapped its src with withStreamToken() (appends the
  short-lived camera-stream token, needed because <img> can't send an
  Authorization header), but the enlarged lightbox <img> used a bare
  ${status.cover_url}?view=top. On auth-enabled instances the backend
  rejected the unauthenticated request and the browser showed the
  broken-image icon. Wrap the enlarged src with withStreamToken() too.
2026-04-20 11:49:45 +02:00
maziggy d0f35e5d60 fix(mqtt): cap task_id at int32 max to prevent P1S dispatch stalls (#1042) 2026-04-20 08:46:57 +02:00
maziggy d3425c7f44 fix(ftp): wait for zombie thread to complete before giving up on download (#1014) 2026-04-20 08:39:09 +02:00
maziggy ea78fe720c fix(obico): clear Status banner on next successful detection cycle (#172) 2026-04-20 08:19:55 +02:00
maziggy 5a28964748 Change the color catalog's default manufacturer filter from "Bambu Lab" to "All Manufacturers" (#1039) 2026-04-20 08:15:11 +02:00
maziggy 66fe4860f8 fix(printers): stop controls row overflowing in Chrome at narrow card widths 2026-04-19 15:12:07 +02:00
maziggy 685c8e5f2c Post work PR #939 2026-04-19 14:58:33 +02:00
Ed b046c2cac4 Enhance plate-clear tracking and visibility in printer cards (#939)
* implement plate clear button, add plate status indicator, enable hide on setting change

* added plate cleared icon

* added smaller plate cleared button on "small" printers view

* tighten layout slightly

* fix(printers): restore plate-clear card controls
2026-04-19 14:54:03 +02:00
maziggy 74527d4124 fix(smart-plug): restore MQTT subscriptions for per-type topic configs on startup (#1010)
Users integrating a Shelly plug through an external MQTT broker
  (ioBroker, Zigbee2MQTT, HA's MQTT broker, etc.) lost the plug's
  power/state/energy readings after every Bambuddy restart. The only
  fix was opening Settings → Smart Plugs, renaming the topic to a dummy
  value, saving, renaming back, and saving again.

  Root cause: three code paths configure an MQTT smart plug's
  subscriptions — the startup restore in main.py, the create route,
  and the update route — and they had drifted. The create/update
  routes used the newer per-type model (mqtt_power_topic /
  mqtt_energy_topic / mqtt_state_topic with per-type paths,
  multipliers and mqtt_state_on_value) while the startup restore was
  still on the legacy single-topic model. Worse, the restore loop
  short-circuited on `if plug.mqtt_topic:`, skipping any plug whose
  topics were only set in the new per-type fields — exactly the shape
  of a Shelly-via-ioBroker config, which publishes power and state on
  separate topics. The "rename, save, rename back" workaround routed
  through the update endpoint and re-established the subscription the
  correct way.

  Extracted the topic-resolution + service.subscribe() call into
  subscribe_plug_to_mqtt() in mqtt_smart_plug.py and routed all three
  paths through it so the schema can't drift again. The helper keeps
  the legacy `mqtt_topic` field working as a fallback for all three
  data types — matching the behaviour the startup restore used to
  have via subscribe()'s internal `effective_*_topic or topic`
  collapsing, and matching the change-detection dict already used
  during updates.

  Regression tests cover: per-type topics restored without a legacy
  topic, legacy single-topic backward compat, per-type multipliers
  overriding legacy, per-type winning when both are set, the
  empty-config skip case, and topic-list de-duplication.
2026-04-19 13:51:58 +02:00