Commit Graph
1024 Commits
Author SHA1 Message Date
maziggy 02eb5f57dc fix(#422): start g-code anchor + slicer placeholder substitution
Auto-Print G-code Injection had two reviewer-reported bugs from the initial
  ship:

  1. Start snippets were prepended to the entire plate_X.gcode, landing
     before the printer's own bed-heat / homing / nozzle-prime sequence —
     so a Swapmod start snippet that assumed nozzle-at-temp ran on a cold
     printer (pleite). Anchor injection at "; MACHINE_START_GCODE_END" so
     snippets land where a slicer-side custom-start-gcode would. Files
     without the marker keep prepend behaviour as a fallback with a
     warning log.

  2. Placeholders like "G1 Z{max_layer_z} F600" were written verbatim;
     firmware parsed them as Z1 and crashed the head into the print on
     tall models — real safety bug (DevScarabyte). Added a header parser
     for the 3MF "; HEADER_BLOCK_START..END" block (lowercased keys,
     [units] suffix stripped, spaces -> underscores) and a Prusa-style
     {name} substitution pass over both start and end snippets before
     injection. Supported placeholders: {max_layer_z} / {max_print_height},
     {total_layer_number} / {total_layers}, {total_filament_weight},
     {total_filament_length}, plus any other normalised header key.
     Unknown placeholders are left verbatim with a warning — a typo never
     silently expands to an empty string.

  16 new regression tests across 4 new classes in test_gcode_injection.py
  (anchored injection + missing-marker fallback, placeholder substitution
  including alias resolution + unknown-pass-through, direct unit tests
  for each new helper). All 2195 backend unit tests pass.

  Wiki print-queue page updated with the supported placeholder list and a
  {max_layer_z} safety callout for park moves.
2026-04-27 16:02:40 +02:00
maziggy 527f8ea471 fix(mqtt): #1136 reprint fails with 0500_4003 SD R/W after stuck dispatch
Reprinting from archives sometimes failed immediately with a MicroSD R/W
  exception, with the printer's MQTT push referencing a 3MF from a
  different unrelated archive. Once it started, every subsequent reprint
  hit the same error until the container was restarted.

  Root cause from @smandon's support package: paho-mqtt's client-side QoS
  1 queue. When the printer's command channel goes half-broken (telemetry
  flowing, publishes silently dropped — same #887/#936 pattern),
  background_dispatch.py:993 hits its 15s deadline and calls
  force_reconnect_stale_session(). That function was force-closing the
  underlying socket so paho's auto-reconnect would kick in, but the same
  mqtt.Client instance, same client_id, and same in-process QoS 1 queue
  stayed alive across the reconnect. Any unacked publish from the broken
  session — typically the just-sent project_file for the new archive —
  got replayed verbatim on the new connection. The queue accumulates
  across multiple stuck dispatches in one Python process, so by the
  second or third stuck reprint there were several stale
  project_file/resume/stop/clean_print_error commands queued together;
  the printer latched onto whichever stale path it processed last,
  couldn't find the file on its SD card, and emitted 0500_4003. Container
  restart was the only thing that wiped paho's in-process queue.

  Replaced socket-close with a context-aware reconnect via a new
  _reset_client_for_reconnect() router:

    Async-context callers (dispatch deadline, FastAPI handlers via
    check_staleness) → hard-reset: client.disconnect() (broker drops
    session, clean_session=True), client.loop_stop() (kills paho's
    network thread and its queue), null _client, fresh connect() with
    incremented client_id. New connection is genuinely empty, no replay.

    Paho-network-thread callers (dev-mode probe + ams_filament_setting
    zombie detection inside _update_state) → socket-close fallback.
    loop_stop() from inside the network thread would self-join and
    deadlock, so the safe pattern there is "close the socket and let
    paho's loop detect it and auto-reconnect on the same client".

  Routing decision uses asyncio.get_running_loop() — paho's callback
  thread has no loop, every legitimate hard-reset caller does.

  7 regression tests:
  - TestForceReconnectRouting (3): sync-context → socket-close fallback,
    async-context → hard-reset with disconnect()+loop_stop()+null,
    state-disconnected broadcast fires once on either path
  - TestHardResetClientDirect (3): helper directly — old client gets
    disconnect()+loop_stop(), _client cleared, failing disconnect()
    doesn't propagate so background_dispatch's await chain can't break
  - TestZombieSessionDetection / TestDeveloperModeProbeTimeout (updated):
    paho-thread context still goes through socket-close, preserving the
    legacy contract for those paths
2026-04-26 15:00:20 +02:00
maziggy 88b5f56eb2 fix: cancel = layer shift, stuck "1 problem", and dropped child-logger logs
Three bugs that surfaced together while debugging an H2D cancel:

  1. Cancelling a print stamped failure_reason="Layer shift" in archives
     AND left the printer card stuck on "1 problem" forever. Four causes:
     (a) POST /printers/{id}/print/stop never set the user-stopped flag, so
         on_print_complete couldn't override "failed" -> "cancelled".
     (b) HMS-derived failure_reason heuristic mapped any module-0x0C HMS to
         "Layer shift". Module 0x0C is "Motion Controller" broadly (includes
         cameras, markers, AND the cancel-sequence echo 0C00_001B). Real
         layer-shift codes live in module 0x03. Same false-positive class
         existed for "Filament runout" (any 0x07) and "Clogged nozzle" (any
         0x05). Replaced with a 23-code curated short-code map; unknowns
         leave failure_reason=None.
     (c) Cancel-echo HMS codes (0300_400C "The task was canceled.",
         0500_400E "Printing was cancelled.") were polluting state.hms_errors
         via both the hms[] and print_error parse paths. Filter them at
         parse time so the frontend never sees them.
     (d) Frontend bucketed gcode_state="FAILED" as a problem unconditionally.
         Real failures attach an HMS error; user-cancels don't — so FAILED-
         without-HMS now buckets as "finished" and only escalates to "error"
         when there's an active known HMS.

  2. logs/bambuddy.log was silently dropping records from named child
     loggers. TraceIDFilter was attached to root_logger, but Python's
     logging only invokes a Logger's filters on records originating at that
     logger — propagated child-logger records skipped it, formatter raised
     KeyError, handler.handleError dropped the record. Moved the filter
     from root_logger.addFilter() to handler.addFilter() on each handler,
     matching the filter's own docstring guidance.

  derive_failure_reason() extracted as a pure function for testability.
  status="cancelled" now symmetrically yields "User cancelled" alongside
  "aborted".

  20 regression tests across:
  - backend/tests/unit/test_failure_reason_derivation.py (11)
  - backend/tests/unit/services/test_bambu_mqtt.py::TestHMSUserActionFiltering (4)
  - backend/tests/unit/test_trace.py::TestFilterMustBeAttachedToHandlerNotLogger (1)
  - frontend/src/__tests__/pages/PrintersPageBucketing.test.ts (5; includes
    the H2D-cancel-echo "FAILED + only unknown HMS" case)
2026-04-26 14:24:28 +02:00
maziggy e9200449ae fix(deploy): kiosk picks up new builds without operator intervention
Reproduced live during the #1133 rollout: the SpoolBuddy display kept
  serving the pre-fix picker for hours after every cache-clear,
  chromium-restart, and pkill attempt because a chain of stale state
  across HTTP cache + Service Worker + persistent profile prevented
  fresh code from reaching the running tab.

  Three independent changes — any one of them sufficient on a clean
  profile, but all three needed to escape an already-corrupted one:

  (1) backend/app/main.py — index.html now served with
  Cache-Control: no-cache, must-revalidate on both / and the SPA
  catch-all. Vite emits content-hashed JS/CSS bundle filenames so the
  assets themselves are safe to cache forever, but the HTML wrapping
  them is the only file that knows which hash is current. Without
  explicit cache directives Chromium falls back to heuristic caching
  (typically 10% of time since Last-Modified) and on long-running
  kiosks happily serves stale HTML across browser restarts. That stale
  HTML references an old bundle hash which is also still in disk
  cache, so the kiosk runs pre-deploy JS forever without ever knowing
  why.

  (2) frontend/public/sw.js — CACHE_NAME bumped from bambuddy-v25 to
  bambuddy-v26 so any client that fetches the new sw.js drops its old
  CacheStorage. The SW does network-first for HTML/JS/CSS but
  intercepts and falls back to cache, and cache-control on HTTP
  responses doesn't reach into the SW's own cache layer.

  (3) spoolbuddy/install/install.sh — generated kiosk launcher now uses
  --user-data-dir=/tmp/spoolbuddy-kiosk-userdata with a pre-launch
  rm -rf, so every kiosk restart starts from a clean slate (no HTTP
  cache, no SW registration, no IndexedDB). Trade-off is a slightly
  slower first paint and zero offline support; neither matters for a
  single-purpose kiosk facing a backend on the same LAN, and the
  guarantee that next-deploy-just-works is worth far more.

  4 new tests in test_static_html_cache_headers.py: index.html on /
  and SPA catch-all paths emit Cache-Control: no-cache,
  must-revalidate; API routes are unaffected (no leak of HTML cache
  directive onto endpoints we want React Query to cache aggressively).

  For existing kiosks already trapped by an old persistent profile,
  operator runs once: rm -rf ~/.config/chromium && systemctl restart
  getty@tty1.service. The new launcher then picks up automatically.
2026-04-26 11:45:39 +02:00
maziggy 096bdd92a8 fix(#1128): broadcast printer_status when awaiting_plate_clear flips
awaiting_plate_clear is a Bambuddy-side flag, not a printer-side one,
  so toggling it does not produce an MQTT push from the printer. Commit
  4e86e8c added the flag to the printer_status payload so MQTT-driven
  broadcasts (e.g. when a print finishes and on_print_complete sets the
  flag to True alongside a state transition to FINISH) carry it. The
  reverse transition didn't: POST /printers/{id}/clear-plate mutated
  PrinterManager._awaiting_plate_clear and persisted to the DB, but
  emitted no printer_status WebSocket update — and the in-main.py
  status-change broadcaster's status_key dedup intentionally excludes
  Bambuddy-side flags, so even a coincidentally-arriving MQTT push
  wouldn't reflect the change.

  The "Mark plate as cleared" button on the printer card disappeared
  "immediately" after a click only because the React Query cache was
  being optimistically updated client-side; clearing the flag through
  any other route (an admin script, a second tab, an automation hitting
  the endpoint directly, the scheduler at print_scheduler.py:1844 when
  dispatching the next queued print) silently left every UI subscriber
  but the originating tab stale until a coincidental status refresh.

  Centralised the broadcast in PrinterManager.set_awaiting_plate_clear
  itself rather than at each call site, so every current AND future
  caller is covered without remembering to wire it up: a new
  _broadcast_status_change(printer_id) private coroutine is scheduled
  alongside the existing _persist_awaiting_plate_clear whenever the flag
  flips under a running event loop. Lazy-imports ws_manager to keep
  printer_manager.py clean of application-layer infra at module-import
  time, short-circuits when get_status returns None (printer
  disconnected — the next reconnect produces a fresh push anyway), and
  swallows ws_manager.send_printer_status failures so the persistence
  path can complete even if the WS layer is temporarily unavailable.

  The same hook is now in place for any other Bambuddy-side flag that
  gets added to printer_state_to_dict later — they'll all need to
  broadcast their own changes for the same reason.

  8 new regression tests in test_printer_manager_status_broadcast.py:
  schedules-on-True/False/loop-running/no-loop/loop-stopped contracts,
  _broadcast_status_change happy path with payload assertion,
  skip-when-no-state, swallow-WS-errors, and an end-to-end live-loop
  test that fires set_awaiting_plate_clear(False) and asserts a
  broadcast lands with awaiting_plate_clear: false in the payload.
  Existing 24 tests in test_scheduler_clear_plate.py continue to pass
  unchanged because they instantiate PrinterManager() without
  attaching a loop (sync unit-test path) — the new _schedule_async
  call short-circuits on the same loop check the existing persistence
  call already used.
2026-04-26 10:08:40 +02:00
maziggy 1878d2aab5 feat(observability): trace ID column on every log line + X-Trace-Id header
Builds on the recent uvicorn-access-log-into-bambuddy.log change.
  Until now the access line told us who called an endpoint, but there
  was no way to tie that line to the application records emitted on the
  server side while handling that request. The rogue stop_print mystery
  on 2026-04-26 left exactly that gap: even with access logs piped in,
  correlating "this POST landed" with "this MQTT publish went out 6 ms
  later" required eyeball-matching timestamps across different loggers.

  A new ContextVar + middleware + logging filter wire a trace ID through
  every record:

    * trace_id_middleware mints an 8-char hex ID per request (or honours
      a sane inbound X-Trace-Id for cross-system correlation), stores it
      in trace_id_var (ContextVar), echoes it on the response as
      X-Trace-Id, and resets the var in finally.
    * TraceIDFilter, attached to root + uvicorn.access, copies the
      current trace_id_var value onto every LogRecord so the format
      string [%(trace_id)s] resolves to the right ID per record.
    * Records emitted outside any request scope (startup, MQTT
      callbacks, scheduler) get a stable "-" placeholder so the column
      stays visually aligned and grep stays simple.

  ContextVars are the right plumbing because asyncio copies the current
  context into every asyncio.create_task, so background work spawned
  from inside a request inherits the same ID without explicit threading.
  request.state can't make that hop. The logging filter also has no
  access to the FastAPI request object — it runs synchronously inside
  the stdlib logging machinery — and the ContextVar is the only
  mechanism that bridges async request scope to sync log emission.

  Inbound X-Trace-Id is hard-validated against [A-Za-z0-9_-]+ (max 64
  chars) before being honoured — a hostile/buggy caller cannot smuggle
  log-injection payloads (newlines, control chars, megabyte blobs) into
  bambuddy.log via the trace ID column; values that fail the gate
  silently trigger a freshly minted server-side ID rather than failing
  the request.

  Middleware is decorated AFTER auth_middleware on purpose: Starlette
  stacks @app.middleware decorators LIFO so the last-decorated runs
  first inbound, making trace stamp the OUTERMOST layer — auth log
  lines and every record emitted on the way down to and back from the
  route handler all carry the same ID.

  Output now correlates as:

    2026-04-26 09:51:39,152 INFO [uvicorn.access] [a4f3b1e7] - "POST
      /api/v1/printers/1/print/stop HTTP/1.1" 200
    2026-04-26 09:51:39,158 INFO [bambu_mqtt] [a4f3b1e7] [SERIAL] Sent
      stop print command

  One grep a4f3b1e7 returns the full causality chain.

  30 new tests: 22 unit (ContextVar placeholder, filter copies value,
  asyncio task propagation, concurrent-request isolation, hex generator
  uniqueness, hostile-payload validator, max-length boundary, all four
  write verbs survive, GET/HEAD/OPTIONS dropped, URL-substring false-
  match guards, edge cases) and 8 integration (X-Trace-Id round-trips,
  body matches header, hostile inbound replaced, overlong inbound
  replaced, ContextVar resets after request, generator format stable,
  each request gets unique ID).
2026-04-26 10:01:17 +02:00
maziggy 352e619ad7 fix(inventory): serialise spool auto-assign per printer to fix Postgres race
Bambu MQTT can deliver two ams_data push frames for the same printer
  ~30 ms apart (observed on H2D + dual AMS at K-profile-load / RFID-read
  boundaries). Each frame triggers on_ams_change in main.py, whose
  auto-assign block reads (printer_id, ams_id, tray_id), decides "no
  existing assignment", and INSERTs via auto_assign_spool — and the two
  callbacks raced in their respective sessions, both deciding to insert,
  with the second commit losing on:

      asyncpg.exceptions.UniqueViolationError: duplicate key value
      violates unique constraint
      "spool_assignment_printer_id_ams_id_tray_id_key"
      DETAIL:  Key (printer_id, ams_id, tray_id)=(1, 0, 0) already exists.

  SQLite's WAL serial-write semantics had been silently swallowing the
  race for ~7 weeks since the spool-assignment feature shipped (latent in
  ec82092b "Sync", 2026-02-12). When optional Postgres support landed in
  610431d6 (2026-04-03) and asyncpg started allowing true concurrent
  transactions, it surfaced. Net impact: log noise + one assignment cycle
  skipped, retried on the next on_ams_change.

  Adds a per-printer asyncio.Lock (_ams_assignment_locks keyed by
  printer_id) wrapping the auto-assign critical section. By the time the
  second callback's session runs the SELECT, the first's commit is
  visible and the early-return "existing assignment" branch fires instead
  of a duplicate INSERT.

  The Spoolman sync block further down in on_ams_change intentionally
  stays OUTSIDE the lock — it's network-bound and idempotent, so
  serialising it would block subsequent AMS callbacks for the duration of
  a remote roundtrip. Per-printer scope keeps unrelated printers fully
  parallel. The auto-unlink block above isn't wrapped because its
  DELETE/UPDATE operations don't have the same constraint surface.

  5 new regression tests in test_ams_assignment_lock.py: same-printer-
  same-lock identity, different-printers-different-lock isolation, second
  acquirer waits for first (proves serialisation), different printers run
  truly in parallel under a held lock (proves per-printer scope), and an
  autouse fixture that resets the module-level dict between tests so
  cross-test loop affinity bugs can't surface.
2026-04-26 09:40:02 +02:00
maziggy 60d0c33172 fix(camera): catch RuntimeError in TLS proxy forwarders for uvloop
The bidirectional forwarders inside create_tls_proxy._handle catch
  (ConnectionError, OSError, asyncio.CancelledError) on writes, but
  uvloop's UVStream.write raises a plain RuntimeError from
  UVHandle._ensure_alive when the underlying handle is already closed.
  asyncio's default selector loop reports the same situation as
  ConnectionResetError, so the bug only surfaced on uvloop — and only at
  the moment ffmpeg (or a snapshot-capture subprocess) dropped its socket
  while the proxy was mid-flush.

  The RuntimeError slipped past the except tuple, escaped the forwarder
  coroutine, and asyncio's client_connected_cb task-exception handler
  logged a noisy multi-line traceback ending in:

      RuntimeError: unable to perform operation on
                    <TCPTransport closed=True ...>; the handler is closed

  Adds RuntimeError to the except tuple in both _fwd_to_server and
  _fwd_to_client (the latter is the actual frame from the bug report —
  server→client is where buffered TLS chunks land after the client has
  gone). The forwarders are intentionally fire-and-forget on tear-down;
  the existing dst.close() in the finally block already handles cleanup.

  No functional regression possible — the connection is already dead by
  the time the exception fires; this only changes whether asyncio logs an
  "Unhandled exception" trace for it.

  2 new regression contract tests in test_camera_tls_proxy.py use
  inspect.getsource to assert both forwarder closures' except clauses
  include RuntimeError. Source-level rather than a runtime test because
  the forwarders are nested closures inside _handle and extracting them
  just for testability would require a pure-cosmetic refactor.

  Latent since 0feed83c (Fix P2S camera TLS compatibility via OpenSSL
  proxy, #661, 2026-03-15) — only commit that ever touched these
  forwarders.
2026-04-26 09:39:36 +02:00
maziggy 9d0418688c fix(#1134): propagate background-dispatch watchdog timeout as job failure
Follow-up to #1042. The post-dispatch watchdog _verify_print_response was
  fire-and-forget — it correctly detected when the printer never transitioned
  (HMS error pending, half-broken MQTT session, plate-clear gate, SD card
  fault) and force-reconnected the MQTT session, but the dispatch job had
  already been marked successful on the optimistic MQTT-publish-acknowledged
  path. The UI carried on showing "Print started successfully" while the
  printer sat idle.

  The watchdog now returns bool and is awaited inline by both call sites in
  _run_reprint_archive and _run_print_library_file. On False the call sites
  raise a RuntimeError carrying a user-actionable message ("Printer did not
  acknowledge print command — state still {pre_state}. Check the printer for
  a pending error...") which routes through the existing _run_active_job →
  _mark_job_finished(failed=True) → background_dispatch WS broadcast path.
  Library-file flow rolls back the freshly-created archive on timeout so no
  phantom row is left behind for a print that never started.

  The watchdog now also accepts subtask_id advancing past pre_subtask_id as a
  definitive "command landed" signal — same as the queue-side watchdog at
  print_scheduler.py:1992 — so slow H2D FINISH→PREPARE transitions (~50 s
  observed) don't false-fail when the printer has clearly accepted the
  project_file but is still in FINISH. Default timeout raised from 15 s to
  90 s to match the queue-side watchdog and give the same headroom on both
  dispatch paths. Brief mid-window MQTT disconnects keep polling instead of
  immediately failing — matches what the queue watchdog already does and
  avoids false-failing on transient telemetry gaps.

  11 new tests in test_background_dispatch_watchdog.py: state-change pickup,
  subtask_id-change pickup with state still FINISH, neither-changed timeout
  plus force_reconnect_stale_session call, pre_subtask_id=None backwards-
  compat, post-dispatch subtask_id=None not counting as a change, brief
  disconnect not short-circuiting the window, persistent disconnect for the
  full window returning False, default-timeout=90s contract, _run_reprint_archive
  raises RuntimeError with the captured pre-state args on watchdog False,
  _run_reprint_archive happy path doesn't rollback, _run_active_job marks
  the job failed with the message when _process_job raises RuntimeError.
2026-04-26 08:58:08 +02:00
Sn0rrii fdaec47378 feat(oidc): Azure Entra ID support — configurable email claim & verification + Remember Me persistent login (#1126)
feat(oidc): add Azure Entra ID support with configurable email claim resolution

Adds two new OIDC provider fields: email_claim and require_email_verified.
2026-04-25 13:32:42 +02:00
maziggy 4304a42542 feat(#729): per-spool category + low-stock threshold override
Two new optional fields on Spool: free-text `category` (max 50) and
  `low_stock_threshold_pct` (1-99). Powers the "differentiate critical
  spools from prototype spools and alert at different thresholds" use
  case from #729 without taking on the full multi-tag taxonomy + auto-
  apply rules + per-tag alert system the ticket originally proposed.

  Form gains:
  - Category input with datalist autocomplete sourced from categories
    already in use, so casing/spelling stays consistent.
  - Per-spool low-stock threshold input. Empty = global default; the
    global value renders as the placeholder.

  Inventory page:
  - New category filter chip (hidden until at least one spool carries
    a category — keeps the chip row uncluttered).
  - Stat-card "Low Stock" count and the "Low Stock" filter both honour
    the per-spool override.

  Plus: rename "Delete Tag" button to "Clear RFID Tag" (the original
  ticket reporter mistook it for a taxonomy-tag delete; the button
  actually clears the RFID UID/UUID off the spool record). Toast key
  renamed from `tagDeleted` to `rfidCleared`.

  i18n: full translations across all 8 locales.

  Tests: 9 new backend schema tests (defaults, partial-update, range
  rejection, max-length); 2 new frontend tests (per-spool threshold
  pulls extra spools into low-stock count, filter chip hidden when no
  categories exist).
2026-04-25 13:24:49 +02:00
maziggy 568835c586 fix(#918): RFID auto-match handles Quick-Add and rejects non-Bambu brands
`find_matching_untagged_spool` is supposed to attach an incoming Bambu
  RFID UUID to a pre-existing manually-logged spool of the same
  material/color so users who log inventory before scanning don't end up
  with duplicate rows. Two bugs meant it almost never worked for the
  actual reporting workflow:

  1. Subtype filter was strict. AMS reports `tray_sub_brands="PLA Basic"`
     → matcher required `Spool.subtype = 'Basic'` exactly. The form's
     Quick-Add mode only requires `material`, so bulk-logged rows have
     `subtype=NULL` and were always excluded → duplicate on first AMS
     read.
  2. Brand wasn't filtered. The docstring claimed brand was matched but
     the WHERE clause didn't include it, so a same-color Polymaker (or
     any non-Bambu) untagged row could acquire a Bambu UUID — silent
     data corruption.

  Fix in the same query: subtype prefers exact match but accepts NULL as
  fallback (CASE in ORDER BY ensures exact wins when both exist); brand
  restricted to NULL or LOWER(brand) LIKE '%bambu%' (covers 'Bambu',
  'Bambu Lab', 'BambuLab', 'bambu lab' — the spellings users actually
  type).

  6 regression tests added in test_spool_tag_matcher.py.
2026-04-25 12:49:00 +02:00
maziggy 35edc036bd feat(notifications): per-event ntfy priority headers (#990)
ntfy supports a Priority header (1=min, 2=low, 3=default, 4=high, 5=urgent)
  that controls escalation on the receiving device, but every event was being
  sent at the server default — so a "50% complete" ping looked identical to
  "print failed" or "printer offline". Add a per-event priority dropdown
  section in the Add/Edit Notification modal (visible only for ntfy, listing
  only enabled events); the backend reads config.event_priorities and emits
  the matching Priority header on POST and PUT (image-attachment) paths.
  Unmapped events fall through to the ntfy server default. Out-of-range
  and non-numeric values are dropped, not clamped, so a misconfigured value
  never silently sends at the wrong urgency. Test sends omit the header by
  design so the test path can't accidentally page someone at urgent priority.

  Backward compatible: existing providers without event_priorities behave
  exactly as before. NtfyConfig.event_priorities is optional; the route
  stores config as a JSON blob so no migration is needed.

  i18n: full translations across all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/
  zh-TW). README, CHANGELOG, and the wiki notifications page updated.

  Tests: 6 backend (Priority set on mapped, omitted on unmapped/missing/
  no-priorities, ignored for bad values, propagated through attachment
  path), 6 frontend (section visible only for ntfy, lists only enabled
  events, save round-trip, edit pre-fill, toggle drops row, non-ntfy
  never writes the key).
2026-04-25 12:33:58 +02:00
maziggy 30cf384b5a fix: render Swagger UI at /docs with a docs-scoped CSP
The global CSP set script-src 'self', so FastAPI's /docs page rendered
  blank: the inline boot <script> and the cdn.jsdelivr.net swagger-ui
  bundle/CSS were both blocked. /redoc and /docs/oauth2-redirect had the
  same problem.

  Branch the security_headers_middleware to emit a docs-scoped CSP for
  those three paths that allows cdn.jsdelivr.net (scripts + styles), the
  FastAPI/Redoc favicon hosts (images), and 'unsafe-inline' for the
  inline boot script. Every other route keeps the stricter SPA policy
  unchanged.
2026-04-25 11:29:16 +02:00
maziggy 12c01f029d Revert "feat(oidc): Azure Entra ID support — configurable email claim & verification + Remember Me persistent login (#1118)"
This reverts commit 50382006b3.
2026-04-25 11:05:32 +02:00
Sn0rrii 50382006b3 feat(oidc): Azure Entra ID support — configurable email claim & verification + Remember Me persistent login (#1118)
feat(oidc): add Azure Entra ID support with configurable email claim resolution
2026-04-25 11:02:06 +02:00
maziggy fcda728af4 feat(#1108): long-lived camera-stream tokens + fix(#1089) audit-pass tweaks
#1108 — Long-lived camera-stream tokens for HA / Frigate / kiosks. Camera-only
  V1, hard 365-day cap (no infinite tokens), pbkdf2 hashed at rest, plaintext
  shown to user exactly once on creation. New "Camera API Tokens" panel under
  Settings → API Keys with self-service create/revoke, styled confirm modal,
  admin "All users" view for leak triage. Auth path: /camera/stream tries the
  existing 60-min ephemeral table first, falls through to the long-lived path.
  Indexed lookup_prefix keeps verify O(1) per token.

  Permission audit: gated the existing API-keys-CRUD + Webhook docs + API
  Browser content behind api_keys:read so non-admins with camera:view land on
  the API Keys tab and see only the Camera Tokens panel they actually have
  permission to use. Grid layout collapses to single column for non-admins.

  Tests: 29 new backend (15 service + 14 integration covering create/list/
  revoke ownership rules, the auth fall-through, scope enforcement, prefix
  collisions) + 6 new frontend tests for the section UI including the new
  modal flow. All 77 backend tests + 21 frontend camera tests pass. Ruff
  clean (lint + format).

  Docs: README updated with fan-out + long-lived-token bullets. Wiki gets a
  new "Long-Lived Camera Tokens" section under features/camera.md (HA YAML
  example, security model, permission requirements, revoke flow). Website
  features.html gets the bullet under Camera Streaming.

  Also includes #1089 follow-up tweaks already merged in this branch:
  _stream_start_times.setdefault for accurate stream_uptime, subscribe()
  RuntimeError retry to close the grace-vs-subscribe race, atomic
  unsubscribe count via the iter_subscriber on_unsubscribe callback.
2026-04-25 10:44:37 +02:00
maziggy 1e3ad697f2 fix(#1089): camera stream fan-out broadcaster
Most Bambu Lab printers only allow one concurrent camera connection, but
  GET /printers/{id}/camera/stream opened a fresh upstream per viewer.
  Two browser tabs → second viewer fails or kicks the first off.

  New MjpegBroadcaster (services/camera_fanout.py) owns one upstream per
  printer and fans MJPEG chunks out to N subscribers. 5 s grace window
  absorbs tab refreshes without reconnecting. Bounded subscriber queues
  drop frames for slow viewers rather than blocking the broadcaster.

  Audit-pass fixes:
  - _stream_start_times set with setdefault() so stream_uptime reflects
    the shared upstream's age, not the most-recent viewer's
  - subscribe() retried once on RuntimeError to close a tiny grace race
  - unsubscribe() returns post-removal count atomically so the detach log
    no longer races with concurrent leavers

  Permission gates unchanged; broadcaster has no FastAPI surface.

  Tests: 13 broadcaster unit tests + 2 integration tests on /camera/stop.
  External-camera path untouched.
2026-04-25 09:56:45 +02:00
maziggy 7f11618e1e Revert "feat(oidc): Azure Entra ID support — configurable email claim & verification + Remember Me persistent login (#1103)"
This reverts commit 365c38483b.
2026-04-24 16:48:59 +02:00
Sn0rrii 365c38483b feat(oidc): Azure Entra ID support — configurable email claim & verification + Remember Me persistent login (#1103)
feat(oidc): add Azure Entra ID support with configurable email claim resolution
fix(oidc): harden email claim resolution, guards, and test coverage
2026-04-24 16:46:50 +02:00
maziggy 794cb6c6bd fix(#1112): write uploads to external folders through to the mount
POST /library/files only rejected the read-only external branch and
  then unconditionally wrote to get_library_files_dir() with a UUID
  filename. The resulting LibraryFile row pointed at the external folder
  via folder_id, so the file showed up in Bambuddy's UI, but the bytes
  physically lived in archive/library/files/ and never touched the mount
  -- invisible from any other machine accessing the NAS/SMB share.

  Writable external uploads now write through to <external_path>/<filename>
  with the original filename preserved, and the DB row matches what scan
  produces (is_external=True, file_path=<absolute mount path>). Collisions
  return 409 instead of silently overwriting; inaccessible or non-writable
  mount returns 400; path-traversal filenames are rejected via resolve +
  relative_to.

  Extract-zip is now rejected against any external folder (not just
  read-only) with a clear "extract on the mount and run Scan" message --
  the nested-subfolder creation path would need mkdir on the mount plus
  matching is_external LibraryFolder rows, which is a separate design.
  Scan already handles that shape.
2026-04-24 16:17:06 +02:00
maziggy 08601b4772 ● fix(#1111): advance queue item when print fails before reaching RUNNING
When a file sliced for the wrong nozzle size is dispatched, the printer
  goes IDLE -> PREPARE -> FAILED without ever entering RUNNING. Completion
  detection required prev=RUNNING or _was_running=True, so on_print_complete
  never fired and the queue item stayed at "printing" forever -- blocking
  every subsequent pending item for that printer (check_queue seeds
  busy_printers from any row in 'printing').

  Fire completion on FAILED from PREPARE or SLICING too. Restricted to
  those two pre-print states so a stale FAILED on first connection
  (prev=None) still can't accidentally advance an unrelated queue item.

  Also populate PrintQueueItem.error_message from the current HMS error
  list via the existing hms_errors.py lookup, so users see e.g.
  "[0500_4038] The nozzle diameter in sliced file is not consistent
  with the current nozzle setting" instead of a blank failure reason.
2026-04-24 16:02:55 +02:00
maziggy 9e938cbc8c Revert "feat(inventory): unified Spoolman inventory UI + Storage Location + AMS deep-link + SpoolBuddy NFC write support (#1063)"
This reverts commit 89f14c57ad.
2026-04-24 14:33:33 +02:00
maziggy 2c482572f3 Revert " fix(spoolman): allow LAN Spoolman in SSRF guard"
This reverts commit 4416fd4577.
2026-04-24 14:33:20 +02:00
maziggy 4416fd4577 fix(spoolman): allow LAN Spoolman in SSRF guard
The SSRF guard added in this PR rejected all RFC-1918 private and loopback
  addresses, which breaks Bambuddy's primary deployment topology — Spoolman
  running on the same LAN as Bambuddy (192.168.x.x, 10.x.x.x, 127.0.0.1).
  Users hit "Spoolman URL must not point to a private, loopback, link-local,
  multicast, or unspecified address" on legitimate setups.

  Rescope the guard to block what's actually dangerous in this context:
  cloud metadata endpoints (AWS/Alibaba IMDS), multicast, unspecified,
  non-http(s) schemes, and numeric-encoded IP bypasses. Loopback and
  RFC-1918 ranges are now explicitly permitted.

  Tests:
  - test_ssrf_blocked_schemes_and_addresses updated with refined block list
  - test_ssrf_allows_lan_spoolman_topologies (new) asserts loopback +
    RFC-1918 are accepted so this regression cannot recur silently
  - TestSpoolmanInventorySSRFSpoolBuddyPath parametrize lists trimmed
2026-04-24 14:18:58 +02:00
Sn0rrii 89f14c57ad feat(inventory): unified Spoolman inventory UI + Storage Location + AMS deep-link + SpoolBuddy NFC write support (#1063)
feat(inventory): replace Spoolman iframe with internal inventory UI

When Spoolman is enabled, the Inventory page now uses the same internal
UI (spool list, create/edit modal, archive, delete, weight sync) backed
by a new proxy layer instead of opening an iframe.
2026-04-24 14:00:45 +02:00
maziggy 9c5c2a765f Post work PR #1070 2026-04-24 13:24:00 +02:00
maziggy c0b6010269 fix(virtual-printer): cert-renewal restart regression + clipboard leak
1. `_cancel_restart_task` self-await guard (manager.py:389-413).
     stop_server() / stop_proxy() are called from inside
     _restart_for_cert_renewal, which runs AS _cert_restart_task.
     Cancelling+awaiting self flagged a CancelledError on the next
     `await` in stop_server, tearing down old listeners but never
     letting start_server run — the VP sat on the expired cert
     until the process was manually restarted, silently defeating
     auto-renewal. Skip when `task is asyncio.current_task()` and
     just clear the reference.

  2. Clipboard fallback textarea leak (VirtualPrinterCard.tsx:66-81).
     The HTTP fallback created a hidden textarea, called
     select() + execCommand('copy'), then removed the textarea.
     If select() or execCommand threw, removal never ran and the
     textarea leaked into the DOM. Move the removal into `finally`
     so it happens regardless of the inner block's outcome.

  Regression tests in test_tailscale.py::TestCancelRestartTaskSelfAwait
  cover both the self-cancel path (must NOT cancel self) and the
  outside-cancel path (must still cancel and await).
2026-04-24 13:08:52 +02:00
maziggy e927ccefb1 feat(docker): Tailscale integration support via host socket mount
Add the Tailscale CLI to the production image and document how to
  enable Let's Encrypt cert provisioning for virtual printers from a
  Docker-deployed Bambuddy.

  - Dockerfile installs `tailscale` from the official Debian repo. Only
    the CLI is used at runtime; tailscaled itself stays on the host.
    The binary is harmless if the socket isn't mounted — the code logs
    an actionable hint and falls back to self-signed certs.
  - docker-compose.yml adds a commented-out volume mount for
    /var/run/tailscale/tailscaled.sock with inline setup instructions.
  - tailscale.py's docker-socket hint now also fires when the binary is
    present but the daemon socket is unreachable (i.e. the new Docker
    pattern), not just when the binary is missing, so users get the
    actionable "mount the socket" message instead of opaque CLI stderr.

  Enabling the integration on a Docker host:
    1. `curl -fsSL https://tailscale.com/install.sh | sh` on host
    2. `sudo tailscale up`
    3. `sudo tailscale set --operator=<user>` for the container PUID
    4. Uncomment the tailscaled.sock mount in docker-compose.yml
    5. `docker compose up -d --force-recreate`
    6. Flip the Tailscale toggle on the VP card
2026-04-24 12:01:22 +02:00
maziggy b99ceb26ed fix(db): dedupe legacy settings rows and add missing UNIQUE(key) index
Legacy SQLite installs created the `settings` table without a UNIQUE
  constraint on `key`. The seed loop's `INSERT OR IGNORE` silently
  degraded to a plain INSERT, so every `systemctl restart` added another
  row of `advanced_auth_enabled` / `smtp_auth_enabled`. After a handful
  of restarts, `scalar_one_or_none()` in is_advanced_auth_enabled() and
  similar sites blew up with `MultipleResultsFound`, 500'ing the login
  flow.

  Run-migrations now deletes dup rows (keeping MIN(id) per key) and
  creates the missing `ix_settings_key` unique index before the seed
  loop. Both ops are idempotent — fresh installs and Postgres already
  have the index, so they no-op.
2026-04-24 11:07:55 +02:00
maziggy e8f252d2b8 Post work PR #701 2026-04-24 10:28:51 +02:00
lietschaend 91a3d391ff feat(virtual-printer): add Tailscale opt-out toggle (closes #701 point 3) (#1070)
* feat(virtual-printer): add Tailscale certificate provisioning
2026-04-24 09:59:09 +02:00
maziggy 0cf7a11f46 fix(#1105): recognise new H2C serial prefix "31B8B" for dual-nozzle detection
Bambu started shipping H2C units with a new serial prefix (`31B8B…`
  observed on a January 2026 unit) instead of the legacy `094…` shared by
  the H2D/H2C/H2S family. Two serial-prefix-driven paths — the K-profile
  edit branch in `kprofiles.py` and the delete-K-profile MQTT command in
  `bambu_mqtt.py::delete_kprofile` — were silently routing the new units
  through the single-nozzle format.

  Match on 5 chars (`31B8B`): covers the 3-char model code plus the two
  revision bytes, leaving the revision-letter slot free to iterate. This
  mirrors the X2D precedent of using a longer-than-3-char prefix when a
  single data point can't confirm family reuse.

  Runtime dual-nozzle detection via `device.extruder.info` count and
  model-string branches (`self.model in ("H2C", "H2D", …)`) are already
  prefix-agnostic — no change needed there.

  - backend/app/api/routes/kprofiles.py: add "31B8B" to is_h2d tuple
  - backend/app/services/bambu_mqtt.py: same in delete_kprofile
  - backend/tests/unit/services/test_bambu_mqtt.py: regression test
    `test_h2c_new_prefix_uses_dual_nozzle_format`
2026-04-24 08:15:07 +02:00
maziggy 689bc04d7b ● feat(#1008): honour reprint dates in archive purge + clarify purge UX
Fix a silent correctness bug: archive purge used `created_at` which is
  pinned to the first print, so reprinting a two-year-old archive yesterday
  would still make it eligible for a 365-day purge. The preview and purge
  queries now age each archive by `COALESCE(completed_at, started_at,
  created_at)` — reprints refresh the clock.

  Also flesh out both purge modals (File Manager + Archives) with an
  explicit "What happens when you click Purge" effects list so users see
  upfront that library files go to Trash (reversible) while archives are
  hard-deleted (irreversible), plus what disk artefacts get removed.

  Backend:
  - services/archive_purge.py: `_last_activity_expr()` helper used by
    preview, purge, and sample query
  - tests/integration/test_archive_purge_api.py: new test covering the
    reprinted-archive case

  Frontend:
  - PurgeOldFilesModal / PurgeArchivesModal: new effects bullet list
  - i18n: reprint-aware ageLabel/description/warning and effects bullets
    across all 8 locales (en/de fully translated, rest English fallback)

  Docs:
  - wiki/features/archiving.md: "How old is measured" note + effects list
  - wiki/features/file-manager.md: "What happens when you click Purge"
    section + explicit age-rule breakdown
  - CHANGELOG: archive auto-purge entry rewritten to mention reprint
    semantics, `archives:purge` permission backfill, and updated test count
2026-04-23 17:38:41 +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 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
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 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 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 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 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 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