Commit Graph
269 Commits
Author SHA1 Message Date
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 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 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 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
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 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 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 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 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 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 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 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
maziggy 936b748127 fix(archive): truncation of large 3MF uploads on sendfile short-return (#1032)
On bare-metal Raspberry Pi OS bookworm / armv7l / Python 3.11, 3MF
  files larger than a few megabytes arrived complete via the
  virtual-printer FTP server but the copy into data/archives/ was
  silently truncated. The archive row was still written, the printer
  card looked fine, and the problem only surfaced later when opening
  the archive — the subsequent zipfile.ZipFile() in
  GET /archives/{id}/plates raised BadZipFile and the UI came up blank
  with no thumbnail, plate list, or filament data.

  Two things conspired:

  1. archive_print() used shutil.copy2, which takes Python's sendfile()
     fast path on Linux. On the reporter's kernel/fs combination
     sendfile returned a short count on the first call for the upload
     sizes hit in practice and the destination ended up truncated.
     Small files completed in one syscall and were fine.
  2. ThreeMFParser.parse() caught the resulting BadZipFile in a bare
     `except Exception: pass`, so the archive pipeline kept going with
     empty metadata and left the bad file on disk — nothing in the
     logs hinted anything had gone wrong until a support bundle came
     in and the "Failed to parse plates" warning fired much later.

  The archive copy is now an explicit chunked read/write with fsync —
  sendfile is not in the path. After the copy, if the source was a
  valid ZIP but the destination isn't, we refuse to create the archive
  row, remove only the truncated file (and the archive directory if
  empty — archive_dir is created with exist_ok=True so rmtree would be
  unsafe if a same-second same-filename collision happened), and log
  both sizes at ERROR so the condition is obvious in future support
  bundles. The parser's silent catch now logs at WARNING for the same
  reason.

  All nine archive_print() call sites already check `if archive:` or
  `if not archive:`, so returning None for corrupted ZIPs propagates
  cleanly without behaviour changes elsewhere.

  Regression tests cover single-chunk and multi-chunk copies, mtime
  preservation via copystat, overwrite of an existing destination, a
  ZIP roundtrip through a multi-megabyte 3MF, the new parser WARNING,
  and a truncation sentinel verifying that zipfile.is_zipfile() flips
  to False on a half-written ZIP — the exact post-condition
  archive_print now trusts.
2026-04-19 13:40:43 +02:00
maziggy c7ad449e4e fix(firmware): parse P2S/X2D wiki anchors without dash and full-width parens (#1030)
The wiki scraper silently returned no versions for P2S and X2D, causing
  Bambuddy to fall back to the Bambu Lab download page, which still listed
  01.01.01.00 as "latest" even though 01.02.00.00 shipped on 2026-04-09.

  Two regex mismatches in _fetch_all_versions_from_wiki():

  1. Heading anchor ids require an optional dash between version bytes and
     date. H2D/X1/H2C/H2S use "h-01020000-20260409"; P2S and X2D publish
     "h-0102000020260409" (no dash).
  2. The text fallback only matched ASCII parens around release dates, but
     P2S, X2D, A1 and A1-mini render dates in full-width parens (YYYYMMDD)
     (U+FF08/U+FF09).

  Anchor regex now accepts an optional dash; fallback accepts both paren
  styles. Added regression tests for both shapes.
2026-04-19 12:27:06 +02:00
maziggy 10c261dcf2 chore(tests): suppress B108 on dummy /tmp test fixtures 2026-04-19 09:48:10 +02:00
maziggy 68920f8c62 Fix virtual printer dropping null-terminated MQTT payloads from OrcaSlicer Linux (#927)
OrcaSlicer's Linux BBLNetworkPlugin publishes MQTT payloads with the
  C-string null terminator included in the length, so decoded messages
  arrived as `{…}\x00`. The strict json.loads() raised JSONDecodeError
  and the publish handler silently returned — pushall, get_version, and
  project_file were never answered, and the slicer hit its 60 s sync
  timeout. Print_queue mode only (proxy mode tunnels MQTT). The b069b521
  serial-adaptation fix was correct but ran past this earlier silent
  failure.

  _handle_publish now strips trailing \x00/whitespace before parsing and
  logs the raw payload on any remaining decode failure so future silent
  variants are visible in support bundles.
2026-04-19 08:04:05 +02:00
Minidoracat baf0716a9a feat(cloud): support China region for token-based login (#1013)
feat(cloud): support China region for token-based login

The /cloud/token endpoint always used the global Bambu API endpoint,
so users with China-region access tokens could not validate their
token. The password login flow already exposes a region selector; this
brings the token flow to parity.
2026-04-18 12:30:01 +02:00
maziggy 115d6fe627 fix(mqtt): unique per-submission IDs for archive reprints (#1011)
Archive reprints and library-file prints built the MQTT project_file
  command with hardcoded project_id="0", subtask_id="0", task_id="0".
  Printers key per-job state (including gcode_start_time) on those IDs,
  so reprints looked like continuations of the same job and third-party
  MQTT observers (OctoEverywhere) reported compounding durations across
  repeat replays — a 40 min job reprinted from archive showed ~1h40m,
  and a second reprint of the same file showed ~4h. BambuStudio mints
  fresh IDs per submission; bambu_mqtt.start_print() now does the same
  using an epoch-millisecond timestamp for all three fields. md5 is
  deliberately left empty to avoid activating firmware md5-validation
  against a digest we can't compute without re-reading the upload.

  Added 6 regression tests in TestStartPrintUniqueIdentityFields
  covering non-zero IDs, md5 stays empty, uniqueness across successive
  submissions, numeric-string format, and blast-radius guard on
  unrelated payload fields. Updated CHANGELOG.
2026-04-18 09:09:21 +02:00
maziggy a2c7fd4542 fix(obico): revert POST-bytes approach — Obico /p/ is GET-only
The 0.2.3b4 #1003 "fix" POSTed JPEG bytes as multipart form data,
  but Obico's /p/ endpoint is declared methods=['GET'] upstream and
  reads ?img=URL from the query string. Every POST was 405'd by
  Flask's router before any handler ran, which is why the Obico
  container logs were silent while Bambuddy kept reporting
  "ML API call failed for printer N:" with a blank suffix —
  raise_for_status() on the 405 produced an exception whose str()
  rendered empty.

  Restored the pre-#1003 nonce-URL approach (commit 3e434458):
  capture locally with a 20s timeout we control, stash the JPEG
  under a single-use 32-byte nonce, hand Obico a
  GET /api/v1/obico/cached-frame/{nonce} URL that resolves in
  <50ms so its hardcoded 5s read timeout never races RTSP.

  Also guards against future silent exceptions: the error format
  now falls back to type(exc).__name__ when str(exc) is empty.
  Detection also early-returns with an explicit error if
  external_url is unset instead of handing Obico a URL it can't
  resolve.

  The #1003 reverse-proxy scenario (Authelia/Authentik/CF Access
  in front of Bambuddy) is addressed by documenting that the
  /api/v1/obico/cached-frame/ path must be whitelisted from
  external auth at the proxy layer — it is already public on
  Bambuddy's side.

  Backend: services/obico_detection.py, api/routes/obico.py,
  main.py (PUBLIC_API_PATTERNS).
  Frontend: FailureDetectionSettings banner + client.ts type +
  all 7 locales restored.
  Tests: 15 unit + 5 integration tests pass.
2026-04-18 08:50:46 +02:00
maziggy 464d56ea0d fix(install): make SpoolBuddy kiosk usable on first boot in full-mode install
Full-mode install booted into an unusable kiosk:
  - Chromium opened before uvicorn → "can't connect to localhost"
  - After reload, requires_setup=true hijacked /spoolbuddy → /setup
  - Touch-only Pi has no keyboard to complete the setup wizard
  - Declining auth left the user at / instead of the kiosk

  Fixes, bundled:

  1. backend/app/cli.py kiosk-bootstrap now, in one DB transaction:
     - creates a scoped API key (can_read_status=True, rest false)
     - upserts setup_completed=true
     so AuthContext never redirects and the kiosk URL loads directly. Users
     who want auth can still enable it from the admin UI; the provisioned
     key keeps working.

  2. install.sh full-mode runs the CLI as the bambuddy service user after
     create_bambuddy_service and sed-replaces the CHANGE_ME_AFTER_SETUP
     placeholder in spoolbuddy/.env.

  3. The generated spoolbuddy-kiosk-launch polls ${backend_url}/health for
     up to 60s before exec'ing chromium, so cold boots wait for uvicorn
     instead of flashing ERR_CONNECTION_REFUSED.

  Standalone mode was unaffected — users supply a real key from their
  existing Bambuddy before install.
2026-04-18 08:14:53 +02:00
maziggy 3502ab33c5 fix(install): auto-provision SpoolBuddy kiosk API key in full-mode install
Full-mode install wrote CHANGE_ME_AFTER_SETUP as SPOOLBUDDY_API_KEY because
  no admin exists yet to create a real one. On reboot the kiosk launched with
  that placeholder, AuthContext rejected it, and the user hit the Bambuddy
  login page instead of the kiosk. Standalone mode was unaffected — users
  paste a real key from their existing Bambuddy before install.

  Adds backend/app/cli.py with a kiosk-bootstrap subcommand that creates a
  scoped APIKey row directly in the DB (can_read_status=True, everything else
  false) and prints the full key to stdout. install.sh full-mode runs it as
  the bambuddy service user after create_bambuddy_service, captures the key,
  and sed-replaces the placeholder in spoolbuddy/.env. Idempotent with
  --force for re-installs.

  Drops the outdated "create an API key and edit .env" next-step block since
  the kiosk is now provisioned automatically.
2026-04-18 07:40:38 +02:00
maziggy a95a3c52ee fix(mqtt): detect zombie sessions via ams_filament_setting response tracking (#887)
After hours idle the MQTT connection can degrade so telemetry still
  flows but published commands never reach the printer.  The existing
  dev-mode probe only ran on first connect; this adds tracking for
  user-initiated ams_filament_setting commands — two consecutive
  unanswered commands (10 s timeout each) trigger force_reconnect.
2026-04-17 09:29:46 +02:00
maziggy 475e34ebda fix(obico): POST image bytes directly to ML API instead of callback URL (#1003)
The ML API previously called back into Bambuddy to fetch snapshots,
  which failed behind reverse proxies with external auth (Authelia, etc.).
  Now the detection loop captures the JPEG locally and POSTs it directly
  as multipart form data — no callback URL, no nonce cache, no
  external_url dependency.
2026-04-17 09:06:31 +02:00
maziggy ef37ffa7c7 fix(obico): exclude snapshot capture PIDs from stream cleanup (#172)
The periodic camera cleanup task scans /proc for ffmpeg processes and
  kills any not in the active-streams registry. The Obico detection
  service's capture_camera_frame_bytes() spawns short-lived ffmpeg for
  snapshots but never registered the PID — so cleanup killed it as
  "orphaned" mid-capture (SIGKILL, exit -9), producing false errors and
  missed detection frames.

  Track capture PIDs in _active_capture_pids and exclude them from the
  cleanup kill list.
2026-04-17 08:38:22 +02:00
maziggy 3e434458a4 fix(obico): capture snapshots locally and serve via nonce URL (#172)
Obico's ML API has a hardcoded 5s read timeout on the URL it fetches, which
  our /camera/snapshot regularly exceeds on cold calls (TLS proxy + ffmpeg +
  RTSP keyframe wait). The detection loop now captures the JPEG locally with
  a 20s timeout we control, stashes the bytes under a single-use 32-byte
  nonce, and hands Obico a new /api/v1/obico/cached-frame/{nonce} URL that
  returns the cached bytes instantly. The 5s ceiling is no longer a factor.

  The nonce is the credential (URL-safe, 256 bits of entropy, single-use,
  30s TTL) so the endpoint can be unauthenticated without widening the
  camera access surface. Replaces the previous camera-stream-token snapshot
  URL approach, which remained vulnerable to the upstream 5s timeout even
  when auth was disabled.

  Thanks to @fblix for the detailed reproducer with timeout numbers.
2026-04-16 11:09:54 +02:00
maziggy 6fb814c5ea feat(printer): add X2D support — camera, dual-nozzle, K-profile, maintenance (#988)
The Bambu Lab X2D (launched April 2026, dual-nozzle, enclosed, hardened
  steel rod gantry, AMS 2 Pro compatible) identifies itself as internal
  model code N6 via SSDP/MQTT, and real serials begin with 20P9. None of
  these identifiers existed in Bambuddy's registries, so the camera
  service fell back to the chamber-image protocol on port 6000 (X2D
  doesn't speak it), firmware-check logged "Unknown printer model: N6",
  and the dual-nozzle K-profile paths — gated on the H2D serial prefix
  "094" — would have treated X2D as single-nozzle.

  Backend:
  - Register N6 → X2D across every registry (PRINTER_MODEL_ID_MAP,
    PRINTER_MODEL_MAP, STEEL_ROD_MODELS, ETHERNET_MODELS,
    CHAMBER_TEMP_SUPPORTED_MODELS, firmware-check API keys + wiki path,
    virtual-printer SSDP/product/serial tables, DB vp_model_fixes).
  - supports_rtsp(): match the X2 display-name prefix and the N6 internal
    code; camera now routes to RTSP on port 322.
  - Dual-nozzle serial prefix check in bambu_mqtt.delete_kprofile and
    kprofiles.set_kprofile broadened to ("094", "20P9") — X2D now takes
    the H2D-style cali_idx in-place edit path.
  - is_h2d model gate in bambu_mqtt.start_print extended with "X2D" so
    timelapse / bed_leveling / flow_cali / vibration_cali / layer_inspect
    are sent as integers and external-spool ams_id 254/255 routing is
    preserved (H2D-style deputy-nozzle addressing).

  X2D uses hardened steel rods like P2S — it is intentionally placed in
  STEEL_ROD_MODELS, not CARBON_ROD_MODELS. A regression-guard test pins
  the classification.

  Frontend:
  - mapModelCode in PrintersPage and SpoolBuddyAmsPage handle N6 and X2D.
  - Enclosure-door badge and airduct-mode whitelists include X2D.
  - MaintenancePage.getMaintenanceWikiUrl routes X2D to P2S wiki URLs for
    steel-rod lubrication, belt tension, cold-pull, and PTFE tube
    (exported to enable direct unit testing).

  Tests:
  - test_printer_models.py: TestX2DModel (10 assertions).
  - test_bambu_mqtt.py: X2D in start_print ams_mapping and is_h2d gate;
    TestDeleteKProfileDualNozzleDetection across H2D, X2D, P2S, X1C.
  - MaintenancePageWikiUrls.test.tsx: 15 assertions covering X2D, P2S
    regression, X1C/H2D/A1Mini regression, and model-name normalisation.

  Docs:
  - README: added X2 series to the supported printers table.
  - CHANGELOG: new entry under 0.2.3b4 Fixed.

  Credit to @krautech for the report and debug bundle, and to @legend813
  for PR #989 which seeded most of the registry changes — rod-type
  classification was corrected (steel, not carbon) and the dual-nozzle /
  K-profile / is_h2d gaps were added on top.
2026-04-16 10:40:32 +02:00
maziggy 46c246c504 fix(archive): resume on subtask_id, short-circuit 550, cache 3mf (#972)
Second wave of #972 — reproducer on a 37.5 MB BambuStudio print to an A1
  showed three stacking root causes when Bambuddy restarts mid-print.

  1. Archive start_time lost on container restart. The name-based dedup
     cancelled any "printing" archive older than 4h and recreated it with
     started_at=now(), so a 13h print that saw a restart 10h in ended up
     showing ~1.5h duration. Persist MQTT subtask_id on every archive and
     match on that first, regardless of age — same id means same print,
     resume in place. Also revives Stale-cancelled rows for users
     upgrading mid-print.

  2. 3MF FTP search tried non-existent paths for ~48 min. Order was
     /cache → /model → /data → /data/Metadata → / with 11×30s retries
     each; BambuStudio actually pushes to / on A1, so the real path was
     tested last. Reorder to / first, and raise a new FileNotOnPrinterError
     sentinel from download_to_file on 550 so with_ftp_retry short-circuits
     via non_retry_exceptions. 425 / SSL EOF / connection resets still
     retry as before.

  3. Cover endpoint and archive flow downloaded the same 36 MB twice and
     competed for the printer's single FTP socket, producing 425 errors
     that fed cause-2's retry storm. Add an in-memory _threemf_path_cache
     keyed on (printer_id, normalized filename); whichever flow fetches
     first populates it, the other reuses the file read-only. Eviction
     runs on on_print_complete and deletes the temp file.

  Backend: 14 new tests across test_bambu_ftp.py and a new
  test_subtask_archive_resume.py. Existing suite: 2737 pass. ruff clean,
  frontend build clean.
2026-04-16 09:36:44 +02:00