Commit Graph
2377 Commits
Author SHA1 Message Date
maziggy abc8e97050 feat(camera): optional snapshot URL override for external cameras (#1177)
go2rtc and several IP cameras still emit a warm-up / black frame on every
  fresh MJPEG connection — even with the v0.2.4b2 warm-up-skip fix it
  slipped through intermittently for @nkm8's setup. His own bisect named
  the clean solution: go2rtc exposes /api/frame.jpeg as a dedicated
  single-frame endpoint that never returns the encoder's stale keyframe.

  Adds an optional external_camera_snapshot_url column on printers. When
  set, every single-frame capture path (snapshot endpoint, [SNAPSHOT]
  notification thumbnails, [PHOTO-BG] finish photo, layer timelapse,
  Obico ML, plate-detect / calibrate-plate) routes through _capture_snapshot
  on the override URL via plain HTTP GET, bypassing the warm-up dance.

  Live view stays on the configured stream URL — only single-frame
  captures use the override. Override is camera-type-agnostic. SSRF guard
  applies (existing _sanitize_camera_url allowlist). Empty string treated
  as unset.

  Settings UI: new "Snapshot URL (optional)" input + Test button under
  External Cameras, hidden for camera_type=snapshot since the live URL is
  already a single-frame source. en + de fully translated; 6 other locales
  seeded with English copy.

  5 backend tests pin the routing contract; 3 frontend tests pin the
  input + debounced PATCH. Documented in
  bambuddy-wiki/docs/features/camera.md with the go2rtc example.
2026-05-03 07:59:02 +02:00
maziggy d6a3139397 fix(frontend): emit relative asset paths so SPA loads under any subpath (#1195)
Vite's default base of '/' baked absolute asset URLs into the built
  index.html (/assets/..., /manifest.json, /img/..., /sw-register.js),
  so any path-prefixed reverse proxy (Traefik, nginx subpath, Cloudflare
  Tunnel with path routing) served the SPA as a blank white page — the
  browser requested assets from the host root and got HTML or text/plain
  back, triggering MIME-mismatch errors on every stylesheet/script.

  Set base: '' in vite.config.ts so the HTML transform emits relative
  URLs everywhere. Update public/sw-register.js to register('sw.js')
  (relative) so SW scope auto-pins to whatever subpath the document
  loaded from.

  Out of scope: API_BASE in client.ts is still absolute. The supported
  HA embedding path remains Webpage panel + TRUSTED_FRAME_ORIGINS, not
  HA Ingress (subpath-aware SPA bootstrapping has too many failure modes
  around PWA scope, push subscriptions, and deep-link reloads to take on
  in core). Documented explicitly in docker.md.

  Reported by @Spegeli, follow-up to #1167.
2026-05-03 07:33:40 +02:00
maziggy 875d80ad3c fix(mqtt): lift paho inflight ceiling to prevent QoS=1 session wedge (#1164)
paho's default max_inflight_messages=20 silently fills on Bambu's broker
  after ~16-20 cumulative commands per session, leaving publish() returning
  success while packets sit in paho's internal queue. force_reconnect heals
  it because the inflight queue is per-session, but it costs one wasted
  user action to trigger.

  Lifting the ceiling to 1000 keeps QoS=1 untouched (deliberately chosen
  for cross-model reliability — A1, P1S, X1C, H2D, P2S, X2D all need it)
  and removes the inflight queue as the bottleneck without changing
  wire-protocol behaviour. The 0.2.4b2 watchdog reconnect stays as
  defence-in-depth.

  Diagnosis credit: RosdasHH's QoS=1/0/2 bisect on #1164.
2026-05-02 16:12:48 +02:00
maziggy b02350d423 fix(security): allow iframe embedding from trusted origins via env var (#1191)
Bambuddy ships strict anti-clickjacking headers (X-Frame-Options:
  SAMEORIGIN + CSP frame-ancestors 'none') by default. Internet-exposed
  deployments need this; same-LAN HA Webpage-panel users do not, and
  SAMEORIGIN is port-strict so HA on :8123 + Bambuddy on :8000 always
  fails. azurusnova hit exactly that case.

  Add TRUSTED_FRAME_ORIGINS env var (comma-separated scheme://host[:port]).
  When set, drop X-Frame-Options entirely (modern browsers honor
  frame-ancestors and the legacy ALLOW-FROM syntax is deprecated /
  inconsistent across vendors) and emit "frame-ancestors 'self' <list>"
  on every CSP-bearing route. Origin validation is strict: only http(s),
  no paths, no query/fragment, no wildcards. Bad entries get a warning
  and are dropped — startup never fails.

  Default behaviour (no env var) is unchanged: X-Frame-Options:
  SAMEORIGIN + frame-ancestors 'none', so existing Docker / bare-metal
  deployments are not affected.
2026-05-02 12:32:02 +02:00
maziggy 31577b8d2b Post work PR #1176 2026-05-02 12:18:00 +02:00
Sn0rrii d0d0be89ea fix(oidc): use preferred_username/name claim for auto-created username (#1173) (#1176)
fix(oidc): use preferred_username/name claim for auto-created username

When auto-creating an OIDC user without a valid email claim, derive the
username from preferred_username or name IdP claims instead of falling
back to the opaque provider_sub[:30].
2026-05-02 12:14:32 +02:00
maziggy 3320c7fd45 feat(printers): AMS slot Load / Unload from the printer card (#891)
The ams_load_filament / ams_unload_filament MQTT primitives existed
  in bambu_mqtt.py but were unused — no HTTP route and no UI. Surface
  both as POST /printers/{id}/ams/load?tray_id={int} and
  POST /printers/{id}/ams/unload, gated on PRINTERS_CONTROL.

  Wire them into the existing AMS slot popover (next to "Re-read RFID")
  and add a popover wrapper on the external spool slot which had none.
  Hidden while the printer is RUNNING, mirroring the RFID re-read
  gating. Both buttons enabled when permission is granted; the printer
  no-ops gracefully if there's nothing to do (matches BambuStudio).

  Dual-extruder H2D Ext-R support is the trickier piece. The existing
  ams_load_filament(254) capture came from a single-extruder printer
  and used slot_id=254, curr/tar=-1. Captured the Ext-R command from
  BambuStudio fresh: it sends ams_id=255, slot_id=0 (the right
  extruder index, NOT a slot index), target=255, and curr/tar = the
  actual right-nozzle temp (read from state.temperatures["nozzle_2"],
  falling back to 215 °C if cold so the printer doesn't reject the
  command on a nonsensical temp). Added that as a new branch in
  ams_load_filament; the existing tray_id=254 branch is preserved
  verbatim — no risk of regression on single-external setups.
2026-05-02 12:10:09 +02:00
maziggy 459cfdc51f fix(virtual-printer): queue mode pins per-slot type+color so scheduler can match colour (#1188)
Edward's diagnosis was exact: the manual /print-queue/ POST extracts
  filament requirements from the 3MF and writes
  required_filament_types + filament_overrides + ams_mapping onto the
  queue item, but the VP queue-mode write path skipped all of that.
  Net effect: scheduler reached its model-only-matching fallback and
  auto-dispatched onto whatever printer was free regardless of loaded
  colour.

  Extract the scheduler's existing _get_filament_requirements 3MF
  parser into a shared helper so the VP path can reuse it. VP's
  _add_to_print_queue now populates required_filament_types
  unconditionally (cheap; helps the scheduler reject obvious type
  mismatches) and writes filament_overrides with force_color_match:
  true per consumed slot when a new per-VP queue_force_color_match
  toggle is on. Default off to preserve current behaviour for
  upgraders.

  UI: new toggle on VirtualPrinterCard, mode-gated to print_queue,
  mirroring the existing auto-dispatch toggle. i18n: en + de
  translated, other 6 locales seeded with English copy.

  Schema: one nullable column on virtual_printers
  (queue_force_color_match BOOLEAN, default 0/FALSE).

  11 new backend tests (8 for the extracted parser, 3 for the VP
  write path) + 6 new frontend tests (toggle render gating, default
  state, click posts queue_force_color_match in update body).
  Existing scheduler tests pass against the refactored helper.
  README, CHANGELOG, website features page, and wiki virtual-printer
  page all updated.
2026-05-02 08:38:46 +02:00
maziggy d81040607e fix(api-keys): slice + slicer-presets routes resolve cloud token via key owner (#1182 follow-up)
turulix's headless slicing pipeline got cloud preset IDs from
  /api/v1/cloud/settings (the /cloud/* gate from #1182 worked), but
  slicing those IDs via POST /library/files/{id}/slice failed with
  "no Bambu Cloud session is stored" — the slice route lives on a
  different router, never saw the api_key_owner stash, and
  _resolve_cloud fell through to the empty auth-disabled global
  Settings token.

  Add a permissive route-level dep that returns the API key's owner
  when the key has the cloud scope and None otherwise (never raises),
  so non-/cloud/* routes can opt in without breaking the local-preset
  path. Wire it into POST /library/files/{id}/slice and
  GET /slicer/presets (same root cause, would hit any UI proxied
  through an API key). The route picks current_user or
  api_key_cloud_owner before deriving user_id.

  Auth gate's None-return for API keys is unchanged — keeping the
  owner-resolution scoped to the routes that actually need a cloud
  token prevents scope creep into routes that fence on
  ``current_user is None``.
2026-05-02 08:10:47 +02:00
maziggy 592ec44705 chore(mqtt): log slicer-launched project_file payload for FTS routing diagnostics (#1162) 2026-05-02 07:47:10 +02:00
maziggy 82a593de95 fix(projects): portal-mounted hover preview for cover thumbnails (#1155)
@smandon flagged the 40×40 cover thumbnail as too small to recognise
  the print and asked for a click-to-enlarge full preview. Enlarging
  the thumbnail itself would shift the card grid layout, so keep the
  small thumbnail and show a 384×384 hover popover with the full image
  in ``object-contain`` rendering (so tall MakerWorld photos aren't
  cropped to a square).

  Why a portal: ProjectCard carries ``overflow-hidden`` (rounded
  corners + color accent bar), so any in-tree popover gets clipped the
  moment it extends past the card. Rendering via
  ``createPortal(..., document.body)`` escapes every ancestor clipping
  context, and ``position: fixed`` with measurements from
  ``getBoundingClientRect()`` keeps the popover pinned next to the
  thumbnail regardless of grid position. ``pointer-events-none`` on the
  popover so it can't intercept hover and create a flicker loop;
  ``z-[100]`` so it stacks above sibling cards.

  Edge handling: if the thumbnail is near the viewport's right edge the
  popover flips to the LEFT side of the thumbnail; vertical position is
  clamped so the popover never overflows the window top or bottom. The
  thumbnail's own ``onClick`` is ``stopPropagation``'d so hovering the
  popover area never accidentally triggers the parent card's
  "open project" navigation.

  Tests: 2 new ``ProjectsPage.test.tsx`` cases — mouseenter mounts the
  popover at document.body level (not nested in the card subtree, which
  would re-introduce the clipping bug, and the assertion catches that);
  mouseleave unmounts it; the popover img points at the same
  cover-image URL as the small thumbnail with ``object-contain``; cards
  without a cover_image_filename never mount the portal-rendering
  component.
2026-05-01 13:23:59 +02:00
maziggy 86b54026f6 fix(inventory): hydrate Extra Colours; render Dual Color as bars; punch up Sparkle + checkerboard (#1154)
@maugsburger surfaced four bugs against the original #1154 multi-colour
  swatch work:

  1. Editing an existing spool always opened with the Extra Colours field
     blank, even when the COLOR preview banner above it was rendering
     correctly from the saved data. ColorSection seeded its local
     ``extraColorsDraft`` via ``useState(formData.extra_colors)`` at
     mount time, but SpoolFormModal opens *before* its own useEffect
     populates ``formData`` from the spool record — so by the time the
     saved value landed, the input had already locked onto ''. The user
     then had to retype the value before saving anything else.

  2. Dual Color and Gradient produced the same diagonal blend
     (``linear-gradient(135deg, A, B)``), so the two variants were
     visually indistinguishable. The whole point of the Dual Color variant
     is that the spool has two distinct bars on the reel — a smooth blend
     defeats it.

  3. Sparkle was almost invisible on card-sized swatches. The original
     4-dot pattern (each ~1px) read fine on the inline 20×20 swatch but
     disappeared on the 60-pixel inventory card banners — exactly where
     the user actually identifies a spool.

  4. Checkerboard cell density scaled with the swatch — the same 4-cell
     pattern was either tiny squares on a small swatch or four huge
     squares on a card-sized banner. The user couldn't tell a translucent
     filament from a multi-colour one because the indicator changed shape.

  Fix:

  - ``ColorSection.tsx``: ref-guarded ``useEffect`` resyncs the draft
    whenever the parent's ``formData.extra_colors`` changes via an
    external update. ``commitExtraColors`` updates the ref before
    calling ``updateField`` so live user typing is round-tripped without
    the resync useEffect clobbering it.

  - ``filamentSwatchHelpers.ts: buildColorLayer``: branch on
    ``effect_type``. ``dual-color`` and ``tri-color`` produce
    ``linear-gradient(to right, c1 0 X%, c2 X% Y%, ...)`` with CSS
    double-position stops (hard line, not blend) and equal-width
    segments. ``gradient`` keeps the original 135° smooth blend. The
    ``multicolor`` conic-gradient path is untouched.

  - ``filamentSwatchHelpers.ts: EFFECT_OVERLAYS.sparkle``: bumped from 4
    dots to 13 flecks in mixed sizes (1 / 1.5 / 2 px) and varying
    opacity (0.65 → 1.0) for a depth-of-field "metal flake" feel.

  - ``filamentSwatchHelpers.ts: buildFilamentBackground``: now returns
    ``{ backgroundImage, backgroundSize }`` so per-layer sizes can be
    applied — painted layers stay ``cover``, the checkerboard gets a
    fixed 12px tile so cell density is constant regardless of element
    size. Updated the three existing call sites (``InventoryPage`` group
    banner + spool card, ``ColorSection`` preview) to spread the style
    object directly. ``FilamentSwatch.tsx`` composes the same per-layer
    sizing inline so its output stays in lockstep.

  Tests: 8 new frontend cases pinning the four fixes — Dual/Tri Color
  hard-split (3 tests + 1 regression guard that Dual ≠ Gradient for the
  same stops), Sparkle prominence (≥ 10 distinct radial-gradient layers
  in the rendered background), checkerboard density (last backgroundSize
  layer is a fixed pixel value, not ``cover``), 4 hydration cases (fills
  when formData arrives via parent update, resyncs when the spool
  changes mid-form, doesn't clobber live user typing, clears when the
  new spool has no extra_colors). Existing buildFilamentBackground tests
  updated for the new return-object shape. Full frontend suite: 1610
  passed; full backend suite: 3598 passed; no regressions.
2026-05-01 13:04:10 +02:00
maziggy 6a1e3454db fix(inventory): hydrate Extra Colours on edit + render Dual Color as hard-split bars (#1154)
@maugsburger surfaced two bugs against the original #1154 multi-colour
  swatch work:

  1. Editing an existing spool always opened with the Extra Colours field
     blank, even when the COLOR preview banner above it was rendering
     correctly from the saved data. ColorSection seeded its local
     ``extraColorsDraft`` via ``useState(formData.extra_colors)`` at
     mount time, but SpoolFormModal opens *before* its own useEffect
     populates ``formData`` from the spool record — so by the time the
     saved value landed, the input had already locked onto ''. The user
     then had to retype the value before saving anything else.

  2. Dual Color and Gradient produced the same diagonal blend
     (``linear-gradient(135deg, A, B)``), so the two variants were
     visually indistinguishable. The whole point of the Dual Color variant
     is that the spool has two distinct bars on the reel — a smooth blend
     defeats it.

  Fix:

  - ``ColorSection.tsx``: ref-guarded ``useEffect`` resyncs the draft
    whenever the parent's ``formData.extra_colors`` changes via an
    external update (modal opening with a spool, or switching to a
    different spool mid-form). ``commitExtraColors`` updates the ref
    before calling ``updateField`` so the user's own typing is round-
    tripped without the resync useEffect clobbering it.

  - ``filamentSwatchHelpers.ts``: ``buildColorLayer`` now branches on
    ``effect_type``. ``dual-color`` and ``tri-color`` produce
    ``linear-gradient(to right, c1 0 X%, c2 X% Y%, ...)`` with CSS
    double-position stops — the colour change is a hard vertical line
    rather than a blend region — and equal-width segments across N stops.
    ``gradient`` keeps the original 135° smooth blend. The
    ``multicolor`` conic-gradient path is untouched.

  Tests: 4 new ``FilamentSwatch.test.tsx`` cases pinning the hard-split
  contract (Dual Color uses ``to right`` not ``135deg``; Tri Color
  renders 3 equal hard-split bars; ``gradient`` keeps the smooth
  diagonal; explicit regression guard that Dual Color and Gradient never
  produce the same CSS string for the same stops). 4 new
  ``ColorSectionExtraColorsHydration.test.tsx`` cases pinning the input
  hydration (fills when formData arrives via parent update, resyncs when
  the spool changes mid-form, doesn't clobber live user typing, clears
  when the new spool has no extra_colors). Full frontend suite: 1608
  passed; full backend suite: 3598 passed; no regressions.

  The minor "Sparkle could be more prominent / checkerboard denser"
  feedback in the same comment is deferred to a separate cosmetic pass —
  the reporter flagged it as finetuning.
2026-05-01 12:53:03 +02:00
maziggy 01a7e6ee93 fix(archive,vp): strip .gcode.3mf properly + sync review/archive name (#1152)
@smandon retested the original #1152 fix on the latest daily and surfaced
  two distinct holes:

  1. ``Path(name).stem`` only strips the *last* suffix, so Bambu Studio's
     default ``Plate_1.gcode.3mf`` exports landed in the archive UI as
     ``Plate_1.gcode`` — never the bare ``Plate_1`` the user expected.

  2. The pending-uploads review card always showed the raw FTP filename,
     while the eventual ``PrintArchive.print_name`` resolved from the 3MF's
     embedded title (or, with the toggle on ``filename``, the stripped stem).
     Net effect: same upload showed two different names depending on which
     view you were looking at, with no way for the toggle to flip both
     views in lockstep.

  Three changes:

  - ``resolve_display_stem`` helper in ``services/archive.py`` strips
    ``.gcode.3mf`` / ``.3mf`` / ``.gcode`` (case-insensitive). Applied at
    the archive-creation site so ``Plate_1.gcode.3mf`` → ``Plate_1`` for
    every flow that produces a ``PrintArchive`` row.

  - ``PendingUpload.metadata_print_name`` (new nullable column) is
    populated at FTP-receive time by peeking at the 3MF's embedded title
    via the existing ``ThreeMFParser``. Read happens once per upload —
    the list endpoint then doesn't have to reopen each 3MF on every
    render. Parser failures are swallowed and the column stays NULL;
    the response model gracefully falls back to the stripped filename.

  - ``PendingUploadResponse.display_name`` is a computed field that
    mirrors ``archive_print``'s exact precedence — ``filename`` toggle
    → stripped stem; ``metadata`` toggle (default) → cached title or
    stripped stem. The frontend's review card reads it (with
    ``upload.filename`` as a defensive fallback) and surfaces the raw
    FTP filename via tooltip so users can still inspect what arrived.

  Migration is one idempotent ``ALTER TABLE pending_uploads ADD COLUMN
  metadata_print_name VARCHAR(255)`` (Postgres/SQLite-safe). Pre-migration
  rows have NULL and degrade to filename-stem behaviour without any
  operator action.

  Tests: 14 unit tests in ``test_archive_display_stem.py`` covering the
  canonical normalisation rules (Bambu Studio default name, mixed case,
  dots-in-the-middle, edge cases like ``.gcode.3mf``-only, full-path
  inputs); 6 integration tests in ``test_pending_upload_display_name.py``
  pinning the response contract (default toggle uses metadata title when
  present, falls back to stripped stem when absent, ``filename`` toggle
  overrides metadata, ``filename`` toggle still strips the double suffix,
  ``GET /{id}`` exposes the same field, whitespace-only metadata behaves
  like absent); 3 frontend tests in ``PendingUploadsPanel.test.tsx``
  pinning the review card's render path (resolved name shown, fallback
  to filename when display_name is empty, raw filename available via
  tooltip). Full backend suite: 3598 passed; frontend build clean; no
  regressions in any flow that previously processed ``.3mf`` /
  ``.gcode`` / non-3D filenames.
2026-05-01 12:34:56 +02:00
maziggy 46468c9602 Updated .github/workflows/cleanup-ghcr.yml 2026-05-01 11:49:46 +02:00
maziggy 133ec72527 feat(api-keys): per-user ownership + opt-in cloud access scope (#1182)
Tim (@turulix) is building a fully automated headless slicing pipeline
  against Bambuddy's API and hit the wall flagged in #665: /cloud/* routes
  resolve cloud_token per-user from User.cloud_token, but the auth gate
  returned None for API-keyed requests, so the route fell back to the
  global Settings-table token, which only carries a value in auth-disabled
  deployments. Net effect on auth-enabled deployments: API keys reached
  the gate just fine, then /cloud/filaments always saw user=None and
  returned 401 / empty results — no path to read slicer presets or the
  filament catalogue that a CLI workflow needs.

  Make API keys carry an owner and route /cloud/* lookups through that
  owner; gate the new capability behind an explicit opt-in scope so
  existing automation doesn't gain cloud-read access on upgrade.

  - APIKey gains user_id (FK to users.id, ON DELETE CASCADE) and
    can_access_cloud (BOOLEAN DEFAULT 0). User-delete route also runs an
    explicit DELETE FROM api_keys WHERE user_id = ? since SQLite ships
    FK enforcement off — same pattern as the existing created_by_id
    cleanup blocks.

  - New cloud_caller dep on /cloud/* routes resolves to the JWT user OR
    the API-key owner stashed by a router-level gate. The auth gate itself
    continues to return None for API keys so #1182's surface stays bounded
    to /cloud/* — without that bound, any route that fences API keys via
    `if current_user is None: raise 403` (e.g. long-lived-token
    management) would silently start accepting them.

  - The /cloud/* router-level dep enforces three independent fences for
    API-keyed callers: user_id IS NOT NULL (legacy keys → 401 with
    recreate copy), can_access_cloud=True (otherwise 403), and owner has
    cloud_token (existing fence, unchanged). Two extra one-shot fence
    errors at create/update time refuse can_access_cloud=True when auth
    is disabled or the key is ownerless.

  - Frontend: APIKey list shows "Cloud" badge on cloud-enabled keys and
    "Legacy" badge on ownerless rows; create form gains an "Allow cloud
    access" toggle, default off. New i18n keys in all 8 locales (en + de
    fully translated, others seeded with English fallbacks pending native
    translation — matches the project's flow for newly-added features).

  Migration: two idempotent ALTER TABLE statements + an index on user_id
  for the auth gate's owner→keys lookup. Postgres-safe.

  Tests: 9 backend integration tests in test_api_key_cloud_access.py
  covering creation flags, the three /cloud/* fences, JWT no-op, and
  deletion CASCADE; 2 frontend SettingsPage tests pinning the badge
  matrix and the create-form contract; 5 daemon unit tests for the
  related SpoolBuddy ssh-key sync work that landed in the same branch.
  Full backend suite: 3578 passed; full frontend suite: 1597 passed; no
  regressions.

  Permission semantics for existing keys: keys created before this
  release become "legacy" and are rejected at /cloud/* with the recreate
  message. Every other endpoint they were used against — queue, status,
  control — is untouched.
2026-05-01 11:43:55 +02:00
maziggy 2aabbe5d37 fix(spoolbuddy): sync SSH key over heartbeat to survive Bambuddy keypair rotation
Bambuddy's SSH keypair under <DATA_DIR>/spoolbuddy/ssh/ regenerates whenever
  the data dir is recreated (volume remount, container recreate, fresh deploy).
  The daemon previously only fetched the pubkey at registration, so any
  rotation after a successful boot left ~/.ssh/authorized_keys pointing at
  a stale public half — every Update click then failed with "Connection
  closed by authenticating user spoolbuddy [preauth]" until the daemon was
  restarted by hand. Each prior registration also appended a fresh entry
  without pruning, accumulating stale Bambuddy-tagged keys indefinitely.

  - HeartbeatResponse now carries ssh_public_key; the heartbeat route reads
    it via the same try/except shape as the register route so a missing or
    unreadable backend key doesn't break telemetry.
  - _deploy_ssh_key() strips lines tagged bambuddy-spoolbuddy and writes
    the current key once. No-op when already in sync (no mtime churn on
    every heartbeat). User-managed entries are preserved.
  - Daemon heartbeat handler calls _deploy_ssh_key when the response
    carries a key, so rotations propagate within one heartbeat instead
    of requiring a service restart.

  Tests: 5 unit (creates-when-missing, replace-stale-pileup, preserve-user-keys,
  idempotent, swallows-write-errors) + 2 backend integration (heartbeat carries
  the key; backend key-read failure leaves ssh_public_key None but the
  heartbeat still 200s).
2026-05-01 10:44:01 +02:00
maziggy 44e4b6b5d7 Workflow: .github/workflows/cleanup-ghcr.yml runs Sundays at 03:00 UTC across both packages, with a manual workflow_dispatch and a dry_run toggle. It builds the live-digest
set the same way we just did, so it won't ever delete a digest still referenced by a tagged manifest.
2026-05-01 09:41:01 +02:00
maziggy ddf3dc0c84 fix(camera): skip MJPEG warm-up frame, return second representative frame (#1177)
_capture_mjpeg_frame returned the very first JPEG it found in the
  bytes stream, but many MJPEG sources — go2rtc most notably, and
  several IP cameras — emit a warm-up frame on the byte that follows
  connection accept: usually the last keyframe held in the encoder,
  typically black or stale until the encoder catches up to live
  content. Subsequent frames on the same connection are fine.

  Result: every code path that opened a fresh capture (snapshot UX,
  finish photos in notifications, timelapse, plate-detection CV,
  Obico ML inference, Settings → Test button) returned a black image
  on go2rtc-fronted cameras.

  Reporter's support log showed every black frame was 11095 bytes
  (pure-black 1280x720 JPEG ≈ 10-15 KB) while real-content frames
  from the same source were 30-45 KB.

  Fix:

  - Read past the first complete JPEG, return the second.
  - Fall back to the first frame if the connection closes / times out /
    hits the 5 MB buffer cap before a second arrives. Without that
    fallback, slow / single-frame streams that pre-fix returned the
    warm-up would post-fix return None — a regression. The fallback
    guarantees we never do worse than current behaviour.
  - Inner while-loop now drains every complete frame already in the
    buffer before pulling the next chunk so high-FPS sources that
    pack multiple frames per chunk are handled correctly.

  Untouched: snapshot / rtsp / usb capture paths, generate_mjpeg_stream
  (live-view fan-out).

  7 new regression tests in TestCaptureMjpegFrameWarmupSkip cover
  two-frames-in-two-chunks, two-frames-in-one-chunk, partial-frame-
  split-across-chunks, single-frame fallback, timeout fallback, zero-
  frame stream returns None, non-200 returns None.

  Latency penalty: at most one frame interval (typically 50 ms - 1 s
  on a steady stream), well within every caller's tolerance window.
2026-05-01 08:43:16 +02:00
maziggy 32b5c42dfb fix(permissions): hide MakerWorld nav entry from users without makerworld:view (#1175)
Backend routes were already gated on makerworld:view, the permission
  was granted to admin + standard-user role defaults, and the frontend
  Permission type union already included 'makerworld:view' — but the
  sidebar's hand-maintained navPermissions map in Layout.tsx had no
  entry for `makerworld`. So `isHidden('makerworld')` always returned
  false, the entry rendered for every authenticated user regardless
  of group permissions, and the only way the user found out they
  couldn't use it was by clicking and getting 403'd by every API call.

  Fix is two lines:

  - Layout.tsx: add `makerworld: 'makerworld:view'` to navPermissions,
    matching every other sidebar entry's gating shape.
  - App.tsx: wrap the /makerworld route in PermissionRoute for defence
    in depth, so a user who knows the URL can no longer reach the page
    directly. Same pattern already used by settings, groups/new, and
    groups/:id/edit two lines below.

  Two new Layout tests pin the contract: with auth enabled and a user
  lacking makerworld:view, the sidebar <a href="/makerworld"> link is
  absent while other links still render; with the permission granted,
  the link renders.
2026-05-01 08:31:19 +02:00
maziggy 59c58362ba fix(printers): copy buttons in Printer Info modal work on plain HTTP (#1174)
PrinterInfoModal's CopyButton only tried navigator.clipboard.writeText(),
  which is gated by the secure-context requirement (HTTPS or localhost).
  On the typical Bambuddy deployment shape — bare-IP HTTP on the LAN —
  navigator.clipboard is undefined; the existing try/catch swallowed the
  TypeError, the icon never flipped to the tick, and nothing landed on
  the user's clipboard.

  Fixed by adding the same off-screen-textarea + document.execCommand('copy')
  fallback that CameraTokensPage's plaintext-token modal already uses for
  plain-HTTP LAN deployments. Gate on `navigator.clipboard && window.isSecureContext`,
  fall back to the legacy path otherwise, and surface the success-tick only
  when the copy actually landed (return early without flipping `copied` if
  execCommand returns false). The try/finally around the textarea guarantees
  DOM cleanup even when the browser throws on a restricted context.

  3 new component tests in PrinterInfoModal.test.tsx cover the secure-context
  happy path (navigator.clipboard.writeText is called with the correct value),
  the plain-HTTP fallback path (execCommand is invoked, no leaked textarea
  left in the DOM), and the finally cleanup when execCommand throws
  synthetically.
2026-05-01 08:21:37 +02:00
maziggy 25eab96817 fix(scheduler): raise plate-clear gate for every terminal status (#1171)
The plate-clear gate added in #961 was raised only when a print ended
  with status completed or failed. Aborted prints (printer self-abort
  or a user stopping the print from the printer's own touchscreen) and
  cancelled prints (user stopping via the Bambuddy queue UI) did NOT
  raise the flag, so the queue scheduler dispatched the next pending
  item ~2 seconds later onto a fouled bed.

  The reporter saw two prints (P1P + P1S) auto-start onto fouled beds
  within seconds of touchscreen-aborts, and explicitly flagged the
  risk of damage to the printer. A third printer behaved correctly
  because its previous print had ended "completed" — the asymmetry he
  noticed was the gate working for one terminal status and not the
  other three.

  Touchscreen-aborts are particularly important to gate. Bambuddy's
  existing "user stopped via UI" override (which translates aborted
  to cancelled when _user_stopped_printers is populated) only fires
  for stops through the Bambuddy queue UI; a touchscreen stop reports
  aborted straight through.

  The original code comment claimed user-cancelled prints don't need a
  plate-clear ack because "nothing printed on the bed". That only
  holds if you cancel right at layer 1; a cancel at hour 11 of a
  12-hour print leaves a fully fouled bed.

  The gate is user-clearable on the Printers page, so worst case a
  user who cancels at layer 1 clicks "Clear Plate" once — that's a
  non-issue compared to auto-dispatching onto material.

  Regression coverage in test_print_lifecycle.py::TestPlateClearGate:
  parametrised across all 4 terminal statuses asserting
  set_awaiting_plate_clear(printer_id, True) is called for each, plus
  a defence-in-depth test that an unrecognised future status string
  never silently raises the gate.
2026-05-01 08:13:52 +02:00
maziggy 4aea4be2bd feat(updates): detect HA Supervisor addon and defer update UI to it (#1167)
Bambuddy already supports running as a Home Assistant addon
  (HA_URL/HA_TOKEN env-var integration since #283, community addon at
  hobbypunk90/homeassistant-addon-bambuddy), but the update UI was
  oblivious to it: HA addon users saw the in-app "Update available"
  banner and, on Settings, the docker-compose snippet — neither of
  which they can act on, since the HA Supervisor owns the addon
  lifecycle.

  Detection uses the SUPERVISOR_TOKEN env var that HA Supervisor
  injects into every addon container; no other environment sets it,
  so the check has zero false-positive surface.

  Backend:
    - new _is_ha_addon() helper in routes/updates.py
    - /updates/check now returns is_ha_addon: bool and extends
      update_method to 'git' | 'docker' | 'ha_addon'
    - /updates/apply checks HA before Docker (HA addons ARE Docker
      containers, so checking docker first would mis-classify) and
      returns an HA-specific message that points to Settings →
      Add-ons → Bambuddy in HA
    - response keeps is_docker: true alongside is_ha_addon: true so
      older frontend bundles still hit a managed-deployment branch
      instead of rendering an Install button that can't work

  Frontend:
    - SettingsPage update card branches on is_ha_addon BEFORE
      is_docker; HA users get a Supervisor-targeted message instead
      of the docker-compose snippet
    - Layout update banner is suppressed for HA addons — HA
      Supervisor surfaces its own update notification natively, so
      Bambuddy's banner would be duplicate noise linking to a page
      that just says "update via HA"
    - Plain Docker deployments are unaffected

  i18n: settings.updateViaHomeAssistant added to all 8 locales with
  full native translations.

  Tests: 3 backend unit tests for _is_ha_addon (present, absent,
  empty-string treated as unset), 3 backend integration tests
  (HA-precedes-Docker rejection on apply; HA branch on check; plain
  Docker branch on check), 2 SettingsPage tests pinning the
  mutually-exclusive UI rendering, 2 Layout tests pinning banner
  suppression for HA and retention for plain Docker.
2026-05-01 08:01:19 +02:00
maziggy 889c8bd87f fix(printers): show correct plate thumbnail on multi-plate 3MFs (#1166)
P1S 01.10.00.00 (and similar firmware revisions) only echo the .3mf
  filename in print.gcode_file, dropping the Metadata/plate_N.gcode path.
  The /cover route's regex falls back to plate 1 — and the printer card
  shows the wrong plate's thumbnail on multi-plate prints.

  Resolution order in the new resolve_plate_id() helper (used by both
  the status route's current_plate_id and /cover):

  1. The plate Bambuddy dispatched. start_print() now records
     (dispatched_plate_id, dispatched_subtask) on PrinterState; the
     subtask check rejects stale records from a previous Bambuddy
     dispatch bleeding into a Studio-direct print on the same project.
  2. plate_(\d+)\.gcode regex on state.gcode_file (existing behaviour
     for firmware that does include the path).
  3. After download, scan the 3MF for a unique Metadata/plate_*.gcode —
     covers per-plate archives sliced separately in Studio without a
     Bambuddy dispatch record.
  4. Default to plate 1.

  Cover-byte cache key simplified to (subtask_name, view_key) now that
  plate resolution is late-bound. clear_cover_cache() already fires on
  every print start, so re-dispatches with a different plate always
  fetch a fresh thumbnail.

  Bambuddy-dispatched prints additionally register the local archive
  3MF in the cover cache at dispatch time, so /cover reads straight
  from the archive directory and doesn't refetch the file over FTP
  from a printer whose FTP server is busy serving the active print.

  Coverage: 5 unit tests for resolve_plate_id, 4 unit tests for the
  dispatch record on start_print, 2 integration tests for the cover
  route (dispatch wins over plate-1 default; 3MF-scan fallback for
  per-plate archive without dispatch record).
2026-04-29 16:51:38 +02:00
maziggy b45ca2a662 feat(printer): support Filament Track Switch (FTS) accessory in print modal (#1162)
The FTS routes any AMS slot to either extruder, so AMS info reports
  bits 8-11 = 0xE (uninitialized) and ams_extruder_map ends up empty.
  The print modal's per-nozzle dropdown filter then hides every loaded
  slot, leaving the user with an empty filament dropdown.

  Detection: parse print.device.fila_switch from MQTT push_status into a
  new FilaSwitchState dataclass on PrinterState; surface it through the
  GET /printers/{id}/status response as a nullable FilaSwitchResponse.

  Frontend: useFilamentMapping and FilamentMapping skip the per-extruder
  filter when fila_switch.installed is true. Slots currently fed into a
  track display an [L]/[R] routing badge in the dropdown so the user
  can see where the FTS is currently routing them.

  Tests: 4 backend unit (TestFilamentTrackSwitchDetection), 2 backend
  integration (status route), 2 hook regression, 2 component regression.
2026-04-29 16:18:45 +02:00
maziggy f45f6a131d Updated Github issue template 2026-04-29 13:08:16 +02:00
maziggy dac6cbfe4e fix(mqtt): reset unanswered counter on any ams_filament_setting response (#1164)
Configuring AMS slots ~6 times in a row would silently stop reaching
  the printer, with filament colours jumping around briefly ~1 min
  later. Root cause was the zombie-session watchdog from #887.

  When an ams_filament_setting response took >10 s (normal under load)
  the watchdog set `_ams_cmd_unanswered=1` and zeroed
  `_last_ams_cmd_time` so it wouldn't re-fire on every status push.
  The response handler that resets the counter required
  `_last_ams_cmd_time > 0` — so when the late response arrived, the
  reset path skipped it, leaving the counter armed at 1. The next
  slow response on a fresh command (possibly minutes or hours later)
  would take the counter to 2 and force-reconnect mid-publish — the
  in-flight command got dropped, surfacing as "Cannot set AMS
  filament setting: not connected" if the user retried during the
  ~1 min reconnect window.

  Fix: drop the `_last_ams_cmd_time > 0` guard. Any
  ams_filament_setting response proves the channel is alive, so the
  counter must reset unconditionally. Real zombie sessions (no
  responses at all for two consecutive >10 s windows) still trip the
  watchdog correctly.

  Regression test in test_bambu_mqtt.py drives the exact reporter
  sequence: watchdog fires (clears timer, increments counter) →
  late response arrives (must reset counter) → next slow response
  (must only count as 1, not 2). Other 10 zombie-detection tests
  still pass.
2026-04-29 12:50:30 +02:00
maziggy 68c4a5b839 fix(updates): install the discovered release tag, not hardcoded origin/main
The in-app updater ran `git fetch origin main && git reset --hard
  origin/main` regardless of which version the GitHub releases API
  reported as latest. So whenever the latest release lived on a branch
  other than main — e.g. during a beta cycle when 0.2.4b1 sits on its
  own branch and main still points at the previous stable — clicking
  Apply Update appeared to succeed but the user actually stayed pinned
  to old main HEAD.

  Fix: extract `_discover_target_release(db)` mirroring the same
  release-API + include_beta_updates selection the GUI's update-check
  already uses, pass the resolved tag (e.g. `v0.2.4b1`) into
  `_perform_update(target_ref)`, and run `git fetch --prune --tags
  origin && git reset --hard <target_ref>`. The fetch now pulls --tags
  so a tag ref is locally resolvable; the reset takes the caller's
  ref instead of a hardcoded branch. apply_update now returns a clear
  error if no release resolves, instead of silently kicking off an
  update that can't land.
2026-04-29 12:17:25 +02:00
maziggy cc5692a283 fix(updates): preserve SSH origin pointing at the right repo
The in-app Apply Update path unconditionally ran `git remote set-url
  origin https://github.com/maziggy/bambuddy.git` before fetching, on
  the theory that systemd service users wouldn't have SSH keys. True
  in production, but it also clobbered every developer's SSH origin
  the moment they tested the upgrade flow against their own checkout.
  Next `git push` then prompted for HTTPS credentials and bounced.

  New behaviour: read `origin` first via `git remote get-url`, parse
  out the (owner, repo) pair using a small helper that handles all
  four canonical forms (git@github.com:owner/repo[.git] and
  https://github.com/owner/repo[.git]), and only rewrite if it doesn't
  already resolve to maziggy/bambuddy. Native installs with no remote
  or pointing at a fork still get reset to the canonical HTTPS URL.

  Three new regression tests in test_updates_api.py:
    - parser accepts SSH/HTTPS, with/without .git, rejects non-GitHub
    - SSH origin pointing at maziggy/bambuddy is preserved (the
      developer-footgun case)
    - origin pointing at a fork still gets rewritten to HTTPS (the
      original behaviour we don't want to lose)
2026-04-29 11:45:30 +02:00
maziggy f6dc3a2661 Updated CHANGELOG 2026-04-29 11:39:42 +02:00
maziggy a85855f2dd fix(updates): run pip install in app_dir, not base_dir, on native installs
Native-install upgrade via the in-app Apply Update button got the new
  code in via `git reset --hard origin/main` but then logged

    ERROR: Could not open requirements file:
    [Errno 2] No such file or directory: 'requirements.txt'

  and continued. The new deps never installed, leaving the user with
  new code but stale dependencies — surfaces as cryptic import errors
  on the next restart.

  Root cause: `pip install -r requirements.txt` ran with
  `cwd=settings.base_dir`. On a native install, systemd sets
  DATA_DIR=$INSTALL_PATH/data so base_dir resolves to the data dir
  (e.g. /opt/bambuddy/data), not the source tree. Pip doesn't walk up
  looking for the requirements file the way git walks up looking for
  .git, so it fails. Same bug affected the optional npm step
  (`frontend_dir = base_dir / "frontend"` doesn't exist).

  Fix: introduce `settings.app_dir` pointing at the source-tree root
  (distinct from `base_dir` only on native installs) and run pip +
  npm with `cwd=settings.app_dir`. Git ops keep using `base_dir`
  because they already work (git walks up).

  Docker users were unaffected — Docker doesn't use the in-app updater
  (image pull replaces it).

  Regression test in test_updates_api.py mocks every subprocess in
  _perform_update, captures their cwd, and asserts the pip step runs
  in app_dir and that requirements.txt actually exists there. Any
  future refactor that re-introduces cwd=base_dir for the pip step
  fails CI before another user trips over it.
2026-04-29 11:39:17 +02:00
maziggy eec9ef4a41 Bumped version 2026-04-29 11:28:34 +02:00
maziggy be6342932f fix(restore): drop tables with CASCADE so orphan FKs can't abort restore
Settings -> Backup -> Restore on a Postgres-backed Bambuddy aborted
  with `cannot drop table printers because other objects depend on it`
  when the live DB held orphan tables from removed features. Legacy
  `spoolman_slot_assignments` / `spoolman_k_profile` from an earlier
  Spoolman integration still sat in the schema with `*_printer_id_fkey`
  constraints back to `printers`, so `metadata.drop_all` (which only
  knows about ORM tables, no CASCADE) couldn't drop `printers` and the
  whole restore aborted before any rows landed.

  Replace `metadata.drop_all` with a `pg_tables`-iterating PL/pgSQL DO
  block that DROPs every public-schema table with CASCADE, then call
  `metadata.create_all` to rebuild the schema. CASCADE removes external
  constraints alongside the table, and a "restore" is intentionally
  destructive — the user has explicitly chosen to wipe the DB and
  replace from backup.

  Two regression tests in test_postgres_restore_drop_cascade.py mock
  the Postgres engine, capture the SQL stream, and assert (a) the
  CASCADE+pg_tables iteration is emitted and metadata.drop_all is
  never called, (b) the drop is scoped to public schema so shared
  Postgres setups aren't taken out.

  SQLite restores go through a separate path and are unaffected.
2026-04-29 11:17:43 +02:00
maziggy 7bc4712480 chore(i18n): full parity across all 8 locales + drop two-tier check
Backfill 276 missing translations across fr / it / ja / pt-BR so all
  8 shipped locales now match en. Most gaps come from features that
  landed with en+de+zh translations only:

    - login.resetPassword.* (12 × 3): fr, it, ja
    - printers.firmwareModal.* (7 × 4): all locales
    - settings.spoolbuddy.* (~40 × 3, plus 18 in ja): admin device-
      control block (unregister / reboot / shutdown / update /
      restart confirms)
    - spoolbuddy.settings.* (13 × 4): kiosk backend & auth + diagnostics
    - virtualPrinter.archiveNameSource.* (4 × 4): from this release

  Also fixes 27 ja and 1 fr placeholder-name mismatches that silently
  broke interpolation at runtime — e.g. printers.activeNozzle used
  {{side}} while the runtime passes nozzle, and several keys had
  {{count}} dropped entirely so the value would never render.

  Drop the STRICT / info two-tier machinery from check-i18n-parity.mjs:
  en is the reference, every other locale is checked identically,
  any drift fails CI. The previous tier was just deferred policy, no
  real distinction.

  All 8 locales now sit at 4492 leaves. Parity script, i18n test suite
  (11 tests), and full frontend build all green.
2026-04-29 10:45:43 +02:00
maziggy a34beaa599 feat(inventory): multi-colour gradients, transparency, visual effects (#1154)
Spool and color_catalog rows carry extra_colors (comma-separated hex
  stops) and effect_type (14 visual variants: surface effects, sheen,
  structural). The shared FilamentSwatch component renders gradient,
  conic, effect overlay, and alpha-checkerboard consistently across the
  inventory grid, table, group banner, card, ColorSection preview, and
  catalog editor. Catalog hex_color accepts #RRGGBBAA so catalog entries
  can carry transparency too.

  The paste field accepts the exact format 3dfilamentprofiles.com puts on
  its filament details pages, so users can copy a multi-colour combo
  directly. The effect dropdown spans the full filament-variant
  vocabulary -- surface effects (sparkle/wood/marble/glow/matte), sheen
  variants (silk/galaxy/rainbow/metal/translucent), and structural
  variants (gradient/dual-color/tri-color/multicolor). None of these
  fields touch MQTT/firmware -- pure visual hint.

  Spool group-key extended to include extra_colors + effect_type so
  "Group similar" no longer collapses visually distinct spools.

  Migrations: 4 idempotent ALTER TABLE ADD COLUMN (Postgres-safe), plus
  ALTER COLUMN hex_color TYPE VARCHAR(9) on Postgres only (SQLite ignores
  VARCHAR length).

  Tests: 42 new backend (35 unit + 7 integration), 20 new frontend (14
  FilamentSwatch + 3 ColorCatalogSettings + 3 InventoryPageGrouping
  regression). 3522 backend + 1582 frontend tests pass; ruff clean.
  Localised across all 8 UI locales.
2026-04-29 09:13:33 +02:00
maziggy 57af8a1c19 feat(projects): URL field + cover photo on project cards (#1155)
Two new project fields: a free-text URL rendered as a one-click
  external-link button beside the project name on every card (opens in a
  new tab, click is e.stopPropagation()-guarded so it doesn't enter the
  project), and a cover photo that replaces the status-icon box with a
  square thumbnail.

  URL is plumbed through ProjectCreate/Update/Response/ListResponse,
  including from-template + create-template flows so it inherits between
  a project and its template. Cover photo is not inherited because the
  file would be shared on disk between source and copy.

  Schema validator rejects anything other than http:// or https://
  prefixes -- <a href> rendering would otherwise execute javascript:
  / data: / file: URLs even with React's default escaping. PATCH uses
  model_fields_set for the URL field so users can clear it by sending
  {"url": null}.

  Cover image storage: Project.cover_image_filename references a file
  Cover image storage: Project.cover_image_filename references a file
  inside the existing archives/projects/{id}/attachments/ dir, but it's
  tracked separately from the attachments JSON list so swap/delete on
  the cover doesn't perturb the user's other attachments. Three routes
  (POST/GET/DELETE /projects/{id}/cover-image) accept only .jpg/.jpeg/
  .png/.gif/.webp (no SVG -- SVG can carry script payloads), replace in
  place (prior file deleted before the new one lands so repeat uploads
  can't accumulate orphans), and self-heal when a DB reference points at
  a vanished disk file by clearing the column and 404'ing.

  GET cover-image is gated by RequireCameraStreamTokenIfAuthEnabled
  (accepts ?token=... query string) -- not the bearer-token gate -- so
  <img src> requests work in both auth-on and auth-off configurations.
  The frontend wraps getProjectCoverImageUrl with withStreamToken(),
  matching the existing pattern from getArchiveThumbnail.

  Permissions: PROJECTS_UPDATE for upload/delete/PATCH, PROJECTS_READ
  gate is implicit via the stream-token credential. Migration: 2
  idempotent ALTER TABLE projects ADD COLUMN. Localised across all 8
  UI languages.
2026-04-29 07:42:09 +02:00
maziggy b6a9d56651 feat(archives): Not Printed / Printed collections (#1153)
VP-uploaded archives land with status='archived' (uploaded but never
  sent to a printer), but the Archives page sidebar only offered
  All/Recent/This Week/This Month/Favorites/Failed/Duplicates -- no way
  to filter "what's still queued in my library" vs "what's been printed."

  Two new collections fill the gap: Not Printed filters to
  status==='archived'; Printed filters to any final-status archive
  (completed/failed/aborted/cancelled/stopped) so users see every
  archive that had a print attempt regardless of outcome (the existing
  Failed collection covers just the failure subset).

  Frontend-only -- the status field was already populated correctly by
  the VP archive paths, this was purely a UI gap. 2 new tests pin the
  filter behaviour against a 4-status fixture.
2026-04-29 07:02:45 +02:00
maziggy c2e7f8eb4b feat(vp): add archive name source toggle (metadata/filename) (#1152)
Slicer-uploaded archives picked up their display name from the 3MF's
  embedded print_name (the creator-baked title); users who renamed a job
  in BambuStudio's "Send to printer" dialog never saw that name surface
  because the FTP filename was only used as a fallback when metadata was
  empty.

  Settings -> Virtual Printer now exposes an Archive name source toggle
  (Metadata / Filename, default Metadata) that flips precedence in
  ArchiveService.archive_print via a new prefer_filename_for_name param.
  All four VP-sourced archive paths read the new
  virtual_printer_archive_name_source setting and forward the flag:
  _archive_file, _add_to_print_queue, POST /pending-uploads/archive-all,
  POST /pending-uploads/{id}/archive.
2026-04-29 06:54:08 +02:00
maziggy 418f5168f5 Updated docker-publish-daily-beta.sh 2026-04-28 18:42:23 +02:00
maziggy c0c9e19e53 docs(changelog): consolidate duplicate Fixed/Added sections in 0.2.4b1
The unreleased section accumulated two ### Fixed and two ### Added
  headers from successive PRs each adding their own subsection instead
  of appending under the existing one. Merge into one section per type,
  in canonical Keep-a-Changelog order (Added / Changed / Fixed /
  Security). All 68 entries preserved verbatim — no content change.
2026-04-28 17:49:59 +02:00
Sn0rrii 78408856cd fix(oidc): Allow auto_link_existing_accounts with custom email claims (Azure Entra ID) (#1142)
chore(i18n): extend parity gate to all locales with strict/info tiers
2026-04-28 17:37:48 +02:00
maziggy 724bc92c22 fix(scheduler): post-dispatch hold prevents H2D Pro double-fire (#1157)
Multi-plate batches scheduled to the same H2D Pro were triple-dispatched
  within ~60 s — observed in user logs as queue items 139/140/141 all
  flipping to status='printing' even though the printer was still
  digesting the first project_file (FINISH for 80-210 s before flipping
  to PREPARE). The DB busy_printers seed at print_scheduler.py:145 was
  empirically missing the in-flight items in this window; without
  database access I cannot pin the exact why, but the guard is unreliable.

  Add a defensive in-memory dispatch hold:
  - _start_print captures (dispatched_at, pre_state, pre_subtask_id) per
    printer
  - check_queue augments busy_printers with any printer still inside its
    hold window (60 s minimum cooldown, 180 s hard timeout)
  - _watchdog_print_start releases the hold once it observes a state or
    subtask_id transition (success path), or on the existing 90 s revert
    (unhappy path), or on disconnect

  Pure additive — alongside the existing seed query and _is_printer_idle.
  Doesn't depend on DB row visibility or on_print_complete firing
  correctly. Per-printer isolated. Watchdog kept as @staticmethod so the
  existing 12 watchdog tests pass unchanged; hold-release calls go
  through the module-level scheduler instance.
2026-04-28 17:19:17 +02:00
maziggy 59b714e857 fix(archives): unbreak project-picker scroll, sort + search (#1151)
- ContextMenu's capture-phase document.scroll listener was firing on
    internal submenu scrolls too, slamming the whole menu shut on any
    wheel / arrow-key / scrollbar interaction past the 300px max-height.
    Handler now ignores scrolls whose target is inside menuRef so only
    page-level scrolls dismiss.

  - Sort projects alphabetically (localeCompare) at every project-picker
    site: Archives context-menu submenu (x2), BatchProjectModal,
    EditArchiveModal, PendingUploadsPanel, FileManagerPage. Native
    <select> sites sort once via react-query's select option; custom
    button lists sort inline.

  - Add filter-by-name search to the Archives "Add to Project" submenus
    (new submenuSearchPlaceholder prop on ContextMenuItem) and to
    BatchProjectModal. Both gated on >5 projects so small libraries
    stay uncluttered. Enter picks the first match.

  - New archives.menu.searchProjects i18n key in all 8 locales (en/de
    translated; six others seeded with English copies pending native
    translation, matching the existing flow).
2026-04-28 16:54:24 +02:00
maziggy d5153f1de3 feat(slicer): live progress + filament discovery polish + OrcaSlicer warning
End-to-end live progress, two correctness fixes, and a UX warning around
  the upstream OrcaSlicer bugs we discovered while testing.

  LIVE PROGRESS
  =============

  Wire OrcaSlicer / BambuStudio's --pipe progress channel through the
  sidecar -> Bambuddy -> persistent toast so a user-initiated slice shows
  "{name} -- Generating G-code (75%) -- 47s" instead of just elapsed time.
  The same wiring covers the SliceModal's filament-analysis preview slice
  (the real slice that fires before profile picking, used to discover
  which AMS slots an unsliced plate consumes) and the embedded-settings
  fallback path triggered by Orca's --load-settings segfault on complex
  H2D models.

  - Sidecar (orca-slicer-api/bambuddy/profile-resolver, separate commit):
    switch /slice from execFile to spawn, mkfifo per request, parse the
    CLI's structured JSON progress events into a per-process
    ProgressStore, expose GET /slice/progress/:requestId.
  - Bambuddy backend: slicer_api.slice_with_profiles + slice_without_profiles
    accept request_id + on_progress, spawn a 1Hz parallel poller that
    forwards each snapshot via SliceDispatchService.set_progress(job_id,
    ...) onto the matching SliceJob; GET /slice-jobs/:id includes the
    latest snapshot on every poll. The 404 from the early-race window
    (POST fired before sidecar's progressStore.start) is treated as a
    retry rather than terminal -- otherwise the poller bailed before any
    progress could ever arrive.
  - /api/v1/slicer/preview-progress/:requestId proxies the sidecar's
    progress endpoint for the modal's filament-discovery flow (the
    /filament-requirements call is server-originated; the browser can't
    reach the sidecar directly).
  - Frontend: SliceJobTrackerContext re-renders the persistent toast with
    the new format when a useful progress frame is present, falls back
    to elapsed-time-only when the sidecar hasn't emitted yet or doesn't
    support progress. SliceModal.FilamentAnalysisSpinner generates a
    per-(source, plate) UUID, polls the proxy at 1Hz, and mirrors the
    inline spinner contents into a separate persistent toast so the
    preview slice doesn't feel silent either.

  CORRECTNESS FIXES
  =================

  - MakerWorld imports were persisting URL-encoded filenames verbatim
    ("stormtrooper-helmet%20h2d.3mf"). Backend now urllib.parse.unquote
    s the manifest-supplied name and the URL path-tail fallback before
    passing to save_3mf_bytes_to_library; frontend defensively
    decodeURIComponent s in the slice toast / analysis spinner so
    already-imported rows display cleanly without a backfill migration.
  - The fallback path's slice_without_profiles call now forwards the
    same request_id + on_progress as the primary slice_with_profiles
    call so the toast keeps updating across the segfault -> embedded-
    settings retry boundary instead of going blank.

  ORCASLICER WARNING
  ==================

  Verified two upstream OrcaSlicer CLI bugs reproduce on the latest
  nightly (2.4.0-dev, 2026-04-28) with the help of an isolated AppImage
  extract and a minimal sentinel-value-injected cube fixture:
    - OrcaSlicer/OrcaSlicer#12426 -- SIGSEGV in
      update_values_to_printer_extruders_for_multiple_filaments on
      painted multi-extruder 3MFs (commented on the existing thread,
      not a new issue)
    - OrcaSlicer/OrcaSlicer#13386 -- CLI strict-validates parameter
      values BambuStudio writes by default (solid_infill_filament: 0,
      tree_support_wall_count: -1, prime_tower_brim_width: -1) and
      rejects with exit 238, even though Orca's own GUI tolerates
      them (filed by us alongside this change)

  Settings -> Workflow -> Slicer card renders an amber inline warning
  under the preferred-slicer dropdown when orcaslicer is selected,
  linking both upstream issues and recommending BambuStudio until the
  fixes land. Option stays pickable -- users who only slice STLs aren't
  affected by either bug.
2026-04-28 16:36:49 +02:00
maziggy 3fa0b621dd fix(slicer): correct multi-color slice output + UX polish across the slice pipeline
The slicer CLI silently substitutes embedded defaults for any AMS slot
  the user didn't supply a profile for. When a multi-color project (e.g.
  a MakerWorld helmet with white shell + grey support filament configured
  project-wide) was sliced for a "single-color" plate, the CLI took the
  user's white pick for slot 1 and quietly filled slot 2 with the source
  3MF's embedded grey support filament — producing a slice the user never
  asked for. Same silent-fallback class as the strip-removal bug.

  Backend `/filament-requirements` now returns the FULL project AMS slot
  list (from project_settings.config) with a `used_in_plate: bool` flag
  per entry. The flag comes from the cached preview slice for unsliced
  files; sliced files (where slice_info.config already pre-filters by
  used_g > 0) get used_in_plate=true on every entry. SliceModal renders
  one dropdown per project slot — slots flagged used_in_plate=true are
  editable, slots flagged false are auto-picked from project metadata
  via the existing colour-match scoring and disabled with a
  "-- not used by this plate" suffix. The wire format always carries a
  profile per project slot, so the CLI never falls back to embedded
  defaults.

  Adjacent UX fixes in the same session, since they all hit the same
  flow:

  - Persistent slice-progress toast: SliceJobTrackerProvider now opens
    a persistent loading toast per active job ("Slicing X -- 47s") with
    a 1Hz elapsed-time tick and replaces it with the existing transient
    success/error toast on terminal state. The previous start+finish
    toast pair left a UX dead zone where users couldn't tell whether a
    long slice was still running.

  - "Analyzing plate filaments..." spinner now shows elapsed seconds and,
    after 5s, a hint that the wait is a one-time preview slice (cached;
    re-opens are instant). Addresses "is anything happening?" on first
    open of an unsliced complex multi-color 3MF.

  - Pre-slice printer-mismatch warning + disabled Slice button: the
    plates response now exposes `source_printer_model` from
    project_settings.config; SliceModal compares against the picked
    printer profile name and surfaces an inline warning + disables Slice
    on mismatch. The CLI rejects cross-printer slices (rc=-16) and used
    to fall back to embedded settings, producing wrong-printer g-code
    that errored at print dispatch.

  - Sliced-archive card now reflects the actually-used filament list,
    not the source's project-wide AMS config:
    slice_and_persist_as_archive reads filament_type / filament_color
    from the sliced output's slice_info.config (which already gates on
    used_g > 0) instead of inheriting from the source archive. A 16+
    swatch card on what was actually a 2-color print was the visible
    symptom.

  - MakerWorld URL-paste resolver enriches each instance with
    `compatibility` + `otherCompatibility` from
    design.instances[].extention.modelInfo. The /instances/hits payload
    omits this so every instance row used to look identical; users
    blindly picked the first one regardless of whether it matched their
    printer.

  Tests: 27 SliceModal tests (2 new for the disabled-row contract +
  3 for printer-mismatch + 4 for multi-color rendering); 4 new
  SliceJobTrackerContext tests for the persistent-toast lifecycle;
  backend filament-requirements / slice-preview / threemf-tools
  suites green.

  i18n: new keys slice.queuedToast, slice.runningToast,
  slice.analyzingPlateFilamentsHint, slice.notUsedByPlate,
  slice.printerMismatch, makerworld.slicedFor, makerworld.alsoCompatible
  across all 8 UI languages (English + German fully translated, the six
  others seeded with English copies pending native translation, matching
  the project's existing flow for newly-added user-facing features).
2026-04-28 14:50:28 +02:00
maziggy c1f69ee0cc fix(slicer): wrong-printer slicing + sliced-archive filament list + per-instance MakerWorld compat
Five stacked slice-pipeline bugs that each made the modal's profile picker
  theatrical for 3MF inputs:

  (1) `_strip_3mf_embedded_settings` removed `model_settings.config` /
      `slice_info.config` / `cut_information.xml` along with
      `project_settings.config`. The CLI silently exited after
      "Initializing StaticPrintConfigs" — exit 0, no result.json — and
      Bambuddy masked the failure by re-running with embedded settings
      and the source's bound printer. Strip removed from the dispatch
      path entirely.

  (2) Standard-tier preset stubs lacked the `type` field, so the CLI
      rejected `--load-settings` with rc=-5 ("input preset file is
      invalid") and the same masking fallback fired. Added
      `_SLOT_TO_PROFILE_TYPE` so each stub carries the right
      machine/process/filament discriminator.

  (3) Sliced-archive cards listed every project-wide AMS slot (16+
      swatches for a 2-color print). `slice_and_persist_as_archive` now
      reads `filament_type` / `filament_color` from the sliced output's
      `slice_info.config` (which `ThreeMFParser` already gates on
      `used_g > 0`) instead of inheriting from the source archive.

  (4) SliceModal had no warning when the picked printer profile didn't
      match the source 3MF — the CLI rejects cross-printer slices
      (rc=-16) and fell back to embedded settings, producing wrong-printer
      g-code that errored at print dispatch. Plates response now exposes
      `source_printer_model`; the modal compares against the picked
      profile name and disables Slice + shows an inline warning on
      mismatch.

  (5) MakerWorld URL-paste resolver listed plate instances without
      showing which printer each was sliced for (`/instances/hits`
      omits compatibility info that lives on `design.instances[]
      .extention.modelInfo`). The resolve route now joins both payloads
      by instance ID and forwards `compatibility` + `otherCompatibility`
      onto each hit; the MakerWorld page renders "Sliced for {primary}"
      + "Also marked compatible: ..." per row.

  Tests: 6 unit tests for `extract_source_printer_model_from_3mf`, 1 for
  filtered filament metadata via ThreeMFParser, 2 for makerworld resolve
  compat-merge (happy path + missing modelInfo), 3 frontend SliceModal
  tests for the printer-mismatch warning + Slice-disabled gate. New i18n
  keys `slice.printerMismatch`, `makerworld.slicedFor`,
  `makerworld.alsoCompatible` across all 8 locales.
2026-04-28 13:58:56 +02:00
maziggy 6497eefe60 ● feat(slicer): multi-color slicing + per-plate filament discovery
The slice modal previously rendered exactly one filament dropdown and
  silently truncated multi-color 3MFs to a single profile, producing wrong
  colours on every multi-filament print. End-to-end fix across sidecar,
  backend, and frontend.

  Sidecar (orca-slicer-api / bambuddy/profile-resolver, separate commit):
    - /slice accepts up to 16 repeated filamentProfile parts; slicing
      service materializes each and joins paths with `;` for
      --load-filaments.
    - /profiles/bundled emits filament_type and filament_colour per leaf
      so the bundled tier carries metadata into the modal.

  Bambuddy backend:
    - SliceRequest gains filament_presets: list[PresetRef]. Validator
      accepts three shapes (multi-color array, source-aware singular,
      legacy bare-int id) and lands them all on a populated array before
      the route handler runs — fully backwards-compatible.
    - SlicerApiService.slice_with_profiles takes filament_profile_jsons:
      list[str] and sends one filamentProfile multipart part per profile
      (in submission order) so the sidecar receives N profiles cleanly.
    - New service slice_preview runs the sidecar's slice_without_profiles
      against an unsliced project file's embedded settings, parses the
      result's slice_info.config, and returns the canonical per-plate
      filament list. Cached by (kind, source_id, plate_id, content_hash)
      with LRU eviction at 256 entries, per-key asyncio.Lock prevents
      thundering-herd; transient sidecar failures are NOT cached so they
      retry naturally; parse failures ARE cached (deterministic property
      of the input, no point re-running).
    - /filament-requirements endpoint chain: slice_info.config (existing,
      sliced files) → preview-slice (new, unsliced project files) →
      project_settings.config + painted-face heuristic with 5% noise
      threshold (sidecar-down fallback).
    - threemf_tools gains extract_project_filaments_from_3mf and
      extract_plate_extruder_set_from_3mf — the latter unions object
      top-level extruder, per-part overrides, and painted-face quadtree
      leaves (1-E nibbles in paint_color attrs of <triangle> elements
      inside per-object .model files).
    - Cloud preset listing no longer fetches per-preset detail (Bambu's
      rate limit at ~10/sec returns 429 on every request for users with
      50+ presets). Unified-listing dedup pass instead backfills metadata
      cross-tier so a cloud entry that wins dedup over a same-named local
      entry inherits the local's filament_type / filament_colour.
    - slice_and_persist_as_archive now reads filament_type / filament_color
      from the SLICED OUTPUT's slice_info.config (via ThreeMFParser, which
      already gates on used_g > 0) instead of inheriting from the unsliced
      source archive. Without this, archive cards for sliced multi-color
      prints showed every project-wide AMS slot — 18 swatches for a
      2-color print — instead of just the filaments actually consumed.

  Frontend:
    - SliceModal multi-step: plate-picker first when the source is a
      multi-plate 3MF, then preset dropdowns. One filament dropdown per
      AMS slot the plate actually uses, each pre-picked by metadata
      match against user's local + standard presets via existing
      colorsAreSimilar / normalizeColorForCompare utils.
    - SliceModal-only tier priority is now local → cloud → standard
      (was cloud → local → standard). Other consumers of /slicer/presets
      keep the existing cloud-first order.
    - Submits filament_presets array; backfills the legacy singular
      filament_preset from the array's first entry for stale-tab
      compatibility.
    - i18n keys added across all 8 locales: slice.filamentSlot,
      slice.tier.{local,cloud,standard}, slice.cloud.{notAuthenticated,
      expired,unreachable}, slice.noPresetsForSlot,
      slice.allPresetsRequired (en + de fully translated; six others
      seeded with English copies pending native translation, matching
      the project's existing flow).
2026-04-28 12:29:35 +02:00
maziggy 4acdcd7203 ● feat(slicer): multi-color slicing + per-plate filament discovery
The slice modal previously rendered exactly one filament dropdown and
  silently truncated multi-color 3MFs to a single profile, producing wrong
  colours on every multi-filament print. End-to-end fix across sidecar,
  backend, and frontend.

  Sidecar (orca-slicer-api / bambuddy/profile-resolver, separate commit):
    - /slice accepts up to 16 repeated filamentProfile parts; slicing
      service materializes each and joins paths with `;` for
      --load-filaments.
    - /profiles/bundled emits filament_type and filament_colour per leaf
      so the bundled tier carries metadata into the modal.

  Bambuddy backend:
    - SliceRequest gains filament_presets: list[PresetRef]. Validator
      accepts three shapes (multi-color array, source-aware singular,
      legacy bare-int id) and lands them all on a populated array before
      the route handler runs — fully backwards-compatible.
    - SlicerApiService.slice_with_profiles takes filament_profile_jsons:
      list[str] and sends one filamentProfile multipart part per profile
      (in submission order) so the sidecar receives N profiles cleanly.
    - New service slice_preview runs the sidecar's slice_without_profiles
      against an unsliced project file's embedded settings, parses the
      result's slice_info.config, and returns the canonical per-plate
      filament list. Cached by (kind, source_id, plate_id, content_hash)
      with LRU eviction at 256 entries, per-key asyncio.Lock prevents
      thundering-herd; transient sidecar failures are NOT cached so they
      retry naturally; parse failures ARE cached (deterministic property
      of the input, no point re-running).
    - /filament-requirements endpoint chain: slice_info.config (existing,
      sliced files) → preview-slice (new, unsliced project files) →
      project_settings.config + painted-face heuristic with 5% noise
      threshold (sidecar-down fallback).
    - threemf_tools gains extract_project_filaments_from_3mf and
      extract_plate_extruder_set_from_3mf — the latter unions object
      top-level extruder, per-part overrides, and painted-face quadtree
      leaves (1-E nibbles in paint_color attrs of <triangle> elements
      inside per-object .model files).
    - Cloud preset listing no longer fetches per-preset detail (Bambu's
      rate limit at ~10/sec returns 429 on every request for users with
      50+ presets). Unified-listing dedup pass instead backfills metadata
      cross-tier so a cloud entry that wins dedup over a same-named local
      entry inherits the local's filament_type / filament_colour.
    - slice_and_persist_as_archive now reads filament_type / filament_color
      from the SLICED OUTPUT's slice_info.config (via ThreeMFParser, which
      already gates on used_g > 0) instead of inheriting from the unsliced
      source archive. Without this, archive cards for sliced multi-color
      prints showed every project-wide AMS slot — 18 swatches for a
      2-color print — instead of just the filaments actually consumed.

  Frontend:
    - SliceModal multi-step: plate-picker first when the source is a
      multi-plate 3MF, then preset dropdowns. One filament dropdown per
      AMS slot the plate actually uses, each pre-picked by metadata
      match against user's local + standard presets via existing
      colorsAreSimilar / normalizeColorForCompare utils.
    - SliceModal-only tier priority is now local → cloud → standard
      (was cloud → local → standard). Other consumers of /slicer/presets
      keep the existing cloud-first order.
    - Submits filament_presets array; backfills the legacy singular
      filament_preset from the array's first entry for stale-tab
      compatibility.
    - i18n keys added across all 8 locales: slice.filamentSlot,
      slice.tier.{local,cloud,standard}, slice.cloud.{notAuthenticated,
      expired,unreachable}, slice.noPresetsForSlot,
      slice.allPresetsRequired (en + de fully translated; six others
      seeded with English copies pending native translation, matching
      the project's existing flow).
2026-04-28 12:29:19 +02:00
maziggy 29e55c15fc Updated update_website_wiki.sh 2026-04-28 12:22:05 +02:00
maziggy 988c00554e feat(slicer): multi-color slicing + per-plate filament discovery
The slice modal previously rendered exactly one filament dropdown and
  silently truncated multi-color 3MFs to a single profile, producing wrong
  colours on every multi-filament print. End-to-end fix across sidecar,
  backend, and frontend.

  Sidecar (orca-slicer-api / bambuddy/profile-resolver, separate commit):
    - /slice accepts up to 16 repeated filamentProfile parts; slicing
      service materializes each and joins paths with `;` for
      --load-filaments.
    - /profiles/bundled emits filament_type and filament_colour per leaf
      so the bundled tier carries metadata into the modal.

  Bambuddy backend:
    - SliceRequest gains filament_presets: list[PresetRef]. Validator
      accepts three shapes (multi-color array, source-aware singular,
      legacy bare-int id) and lands them all on a populated array before
      the route handler runs — fully backwards-compatible.
    - SlicerApiService.slice_with_profiles takes filament_profile_jsons:
      list[str] and sends one filamentProfile multipart part per profile
      (in submission order) so the sidecar receives N profiles cleanly.
    - New service slice_preview runs the sidecar's slice_without_profiles
      against an unsliced project file's embedded settings, parses the
      result's slice_info.config, and returns the canonical per-plate
      filament list. Cached by (kind, source_id, plate_id, content_hash)
      with LRU eviction at 256 entries, per-key asyncio.Lock prevents
      thundering-herd; transient sidecar failures are NOT cached so they
      retry naturally; parse failures ARE cached (deterministic property
      of the input, no point re-running).
    - /filament-requirements endpoint chain: slice_info.config (existing,
      sliced files) → preview-slice (new, unsliced project files) →
      project_settings.config + painted-face heuristic with 5% noise
      threshold (sidecar-down fallback).
    - threemf_tools gains extract_project_filaments_from_3mf and
      extract_plate_extruder_set_from_3mf — the latter unions object
      top-level extruder, per-part overrides, and painted-face quadtree
      leaves (1-E nibbles in paint_color attrs of <triangle> elements
      inside per-object .model files).
    - Cloud preset listing no longer fetches per-preset detail (Bambu's
      rate limit at ~10/sec returns 429 on every request for users with
      50+ presets). Unified-listing dedup pass instead backfills metadata
      cross-tier so a cloud entry that wins dedup over a same-named local
      entry inherits the local's filament_type / filament_colour.

  Frontend:
    - SliceModal multi-step: plate-picker first when the source is a
      multi-plate 3MF, then preset dropdowns. One filament dropdown per
      AMS slot the plate actually uses, each pre-picked by metadata
      match against user's local + standard presets via existing
      colorsAreSimilar / normalizeColorForCompare utils.
    - SliceModal-only tier priority is now local → cloud → standard
      (was cloud → local → standard). Other consumers of /slicer/presets
      keep the existing cloud-first order.
    - Submits filament_presets array; backfills the legacy singular
      filament_preset from the array's first entry for stale-tab
      compatibility.
    - i18n keys added across all 8 locales: slice.filamentSlot,
      slice.tier.{local,cloud,standard}, slice.cloud.{notAuthenticated,
      expired,unreachable}, slice.noPresetsForSlot,
      slice.allPresetsRequired (en + de fully translated; six others
      seeded with English copies pending native translation, matching
      the project's existing flow).

  Permissions: no new endpoint paths added. Preview-slice runs inside
  /filament-requirements (LIBRARY_READ / ARCHIVES_READ) and multi-filament
  dispatch runs inside POST /slice (LIBRARY_UPLOAD). No auth surface
  widened.

  Tests: 6 SliceRequest schema tests for multi-filament + legacy-new
  precedence; 9 unit tests for slice_preview cache behaviour (LRU
  eviction with lock cleanup, content-hash invalidation, concurrent
  thundering-herd guard, no-cache-poison on transient sidecar failure);
  15 unit tests for the two new threemf_tools helpers (5 + 10 cases
  including the 60/40 painted-threshold regression pin); a multi-filament
  wire-format test pinning the multipart part count + order; 22 frontend
  SliceModal tests covering plate picker, multi-color render,
  metadata-aware pre-pick, manual override, and the new tier order.
2026-04-28 12:20:59 +02:00