Commit Graph
551 Commits
Author SHA1 Message Date
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 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 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 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 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 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 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
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 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 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 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
maziggy 61c15aac03 feat(slicer): unified Cloud/local/standard presets + harden 3MF profile path
UNIFIED PRESET LISTING (the main feature)

  The initial slicer integration only saw DB-backed local imports — users
  without imported profiles got an empty Slice modal even when their
  Bambu Cloud account or the slicer sidecar carried perfectly usable
  presets. The Slice modal now pulls from three tiers in priority order:

    - cloud:    user's own Bambu Cloud presets, fetched live.
    - local:    DB-backed imports.
    - standard: slicer-bundled stock profiles via the sidecar's new
                GET /profiles/bundled endpoint.

  Listing endpoint: GET /api/v1/slicer/presets

    - Name-based dedup, cloud > local > standard, within-tier order
      preserved exactly. A preset that exists in multiple tiers only
      renders in the highest-priority one.
    - cloud_status (ok / not_authenticated / expired / unreachable)
      drives a precise modal banner instead of an unexplained empty
      list.
    - Cloud branch: per-user cache, 5 min TTL, key
      (user_id, sha256(token)[:16]) so logout/login or token rotation
      auto-invalidates without callback wiring from the cloud-auth
      routes.
    - Bundled branch: global cache, 1 h TTL.
    - Bundled URL respects preferred_slicer (bambu_studio vs orcaslicer)
      so BambuStudio installs see the bambu sidecar's bundled list, not
      OrcaSlicer's.

  Slicing endpoint: POST /library/files/{id}/slice + /archives/{id}/slice

    - Body now accepts source-aware {source, id} triplets per slot:
        printer_preset:  PresetRef
        process_preset:  PresetRef
        filament_preset: PresetRef
    - Legacy *_preset_id integer fields kept for backwards-compat. The
      schema validator normalises bare ints into
      PresetRef(source='local', id=str(int)) so the route handler only
      deals with one shape.

  New preset_resolver service fetches the JSON content per source:

    - cloud:    BambuCloudService.get_setting_detail(id), unwraps the
                `setting` envelope (falls back to top-level for minor
                shape variants).
    - local:    DB read with preset_type slot validation (existing path,
                factored into the new helper).
    - standard: minimal {name, inherits, from: "system"} stub — the
                sidecar's profile-resolver flattens it against
                BUNDLED_PROFILES_PATH/<category>/<name>.json with no
                preset-content round-trip from Bambuddy.

  PERMISSIONS

    - Listing route gate: LIBRARY_UPLOAD (matches the slice action — any
      user who can slice can populate the dropdowns).
    - Cloud branch in BOTH the listing helper and the resolver checks
      CLOUD_AUTH independently — a user with LIBRARY_UPLOAD but not
      CLOUD_AUTH doesn't see the cloud tier (returns 403 if they try
      to slice with a cloud preset) even if a leftover User.cloud_token
      survived a permission revocation. Cloud listing path
      short-circuits the token lookup entirely on the gate-fail branch.

  FRONTEND — SliceModal

    - Calls api.getSlicerPresets() instead of api.getLocalPresets().
    - Dropdowns render <optgroup> per tier with localised section
      labels (Cloud / Imported / Standard).
    - Default selection follows cloud > local > standard priority on
      first load (auto-pick fires once when the data arrives, manual
      choices stick after that).
    - Cloud-status banner renders three variants
      (sign-in / expired / unreachable) only when status != 'ok'.
    - Slice button submits source-aware refs; legacy integer payload
      is preserved server-side for older clients.

  3MF PROFILE-PATH HARDENING (shipped together because they touch the
  same code paths)

  (1) Strip widened. _strip_3mf_embedded_settings only removed
      Metadata/project_settings.config. Real-world Bambu Studio /
      OrcaSlicer 3MFs also carry model_settings.config, slice_info.config,
      and cut_information.xml — any single leftover trips the CLI's
      input validation and the slice falls back to embedded settings,
      making the SliceModal's profile picker theatrical for 3MF inputs.
      Now removes all four configs via a centralised
      _STRIPPABLE_3MF_CONFIGS frozenset with per-file rationale;
      geometry (3D/3dmodel.model), thumbnails, multi-part data
      preserved.

  (2) Sidecar 5xx error capture. slicer_api.py was reading only
      `message` from sidecar 5xx responses and dropping `details`, so
      every CLI failure surfaced as the unhelpful generic
      "Failed to slice the model". New _format_sidecar_error helper
      combines both fields, falls back to plain-text body for
      non-JSON 5xx (nginx 502s, gateway timeouts), replaces the four
      duplicated extraction blocks. Pairs with the orca-slicer-api
      fork's bambuddy/profile-resolver branch which now emits
      `details` on AppError responses (d9c6121) and captures CLI
      stderr in the failure path (fb928c8).

  CARE TAKEN — additive on existing surfaces

    - main.py:               +1 import, +1 router register
    - slicer_api.py:         +list_bundled_profiles, +_format_sidecar_error
                             (dedupes the 4 message-extraction blocks);
                             no existing method behaviour changed
    - library.py:            resolver swap inside _run_slicer_with_fallback,
                             user_id threaded through two callers,
                             strip widened
    - schemas/slicer.py:     PresetRef added, *_preset fields added,
                             legacy *_preset_id kept; validator normalises
    - 4 new files:           schema, route, resolver, tests
    - No existing route URL changed, no existing field removed, no
      behaviour change for clients still sending bare integer ids.

  TESTS

    - 17 unit tests for the listing endpoint helpers
    - 11 unit tests for the source-aware resolver
    - 6 schema tests for SliceRequest legacy + new shapes
    - 3 unit tests for the new sidecar error-detail capture
    - Strip integration test extended to assert all 4 configs go and
      geometry stays
    - 12 frontend tests for SliceModal covering tier-priority
      auto-selection, <optgroup> grouping, fallback paths, source-aware
      payload on submit, manual override across tiers, archive vs
      library routing, error display, all three banner variants

  Verified: 3394 backend + 1531 frontend tests pass, ruff clean,
  frontend production build clean.

  Pairs with three already-pushed commits on the orca-slicer-api fork's
  bambuddy/profile-resolver branch:

    - 5fd6bc6  feat(profiles): add GET /profiles/bundled
    - d9c6121  fix(error): include causeMessage in JSON response as `details`
    - fb928c8  fix(slicing): include CLI stdout/stderr in failure causeMessage
2026-04-27 19:15:31 +02:00
MartinNYHC 8829bc2cc6 Merge branch 'dev' into feature/slicer-api 2026-04-27 17:09:42 +02:00
maziggy d81e4853ec fix(#1112): cross-boundary file move actually relocates bytes
@Carter3DP's report on 0.2.4b1: a file moved into an external (NAS)
  folder showed up in Bambuddy under that folder but was never written
  to the mount. Traced to move_files only updating file.folder_id in
  the DB while leaving the bytes in library_files_dir/. Direct upload
  to a writable external folder was already fixed in 0.2.4b1; the move
  path was not.

  Cross-boundary moves now physically relocate the bytes through a new
  _move_file_bytes helper. Same-boundary moves (managed -> managed)
  keep the existing DB-only fast path because a managed file's on-disk
  location doesn't depend on which managed folder owns it.

  Four flows:
    - managed   -> external: copy to <mount>/<filename>, set
                             is_external=True, store the absolute path,
                             unlink the managed source
    - external  -> managed:  copy to internal storage with a fresh UUID
                             name, set is_external=False, store the
                             relative path, unlink the external source,
                             recompute file_hash (scan-tracked rows
                             carry file_hash=None)
    - external  -> external: same shape as managed -> external
    - managed   -> managed:  DB-only

  Copy-then-unlink ordering means a partial copy followed by a failed
  unlink leaves both copies on disk rather than losing the source if
  the target write fails halfway through on a flaky NAS mount. Failed
  shutil.copy2 cleans up partial dest before raising.

  Defence-in-depth skips:
    - source on a read-only external mount (move = delete-on-source
      which a RO mount can't fulfil)
    - filename collision on the target mount
    - traversal-style filenames after Path.resolve()
    - missing source on disk
    - os.access(W_OK) on the target mount

  Each skip carries a structured {file_id, code, reason} entry in a new
  skipped_reasons field on the response so the UI can surface "5 of 10
  skipped: 3 collisions, 2 missing on disk" instead of a blank number.
  The {moved, skipped} numeric counters are preserved so existing
  frontend code keeps working.

  6 new integration tests in test_external_folders_api.py::
  TestCrossBoundaryMove covering: managed -> external relocates bytes
  (the actual fix), external -> managed relocates bytes including hash
  recompute, name collision skip with the pre-existing target file
  intact, source-readonly skip, managed -> managed stays DB-only, and
  skipped_reasons always present.
2026-04-27 16:48:47 +02:00
maziggy 6deaa513af ● feat(slicer): server-side slicing via OrcaSlicer / Bambu Studio sidecar
Adds an optional slicer-api/ Compose stack and wires Bambuddy's File
  Manager, Archives, and MakerWorld pages to a new server-side Slice flow.
  Slicing runs as an in-memory background job (POST returns 202 + job_id,
  polled via GET /api/v1/slice-jobs/{id}) so a multi-minute slice no
  longer pins the modal; result lands as a new .gcode.3mf in the same
  folder (or new archive for archive sources) with the embedded
  thumbnail extracted.

  Backend
  - New services: slice_dispatch (in-memory dispatcher, 30min retention
    sweep) and slicer_api (HTTP bridge with 4xx/5xx/connection error
    split that drives the 3MF embedded-settings fallback retry path).
  - New schemas: SliceRequest, SliceResponse, SliceArchiveResponse,
    SliceJobEnqueueResponse.
  - New routes: POST /library/files/{id}/slice,
    POST /archives/{id}/slice, GET /api/v1/slice-jobs/{id} (gated on
    LIBRARY_READ since job IDs are sequential and the body leaks source
    filenames and result IDs).
  - AppSettings + env defaults: use_slicer_api, orcaslicer_api_url,
    bambu_studio_api_url. DB-stored values override env defaults.

  Frontend
  - New SliceModal handles preset gating; enqueues then closes
    immediately.
  - New SliceJobTrackerProvider polls active jobs at app level, surfaces
    a single toast per job (queued -> running -> completed / failed)
    and invalidates library/archives queries on terminal status.
  - Settings -> Workflow -> Slicer card: preferred slicer dropdown,
    Use Slicer API toggle, contextual sidecar URL field.
  - File Manager / Archives / MakerWorld get a Slice button gated on
    the Use Slicer API setting.
  - gcode-viewer adapter learns ?library_file=<id> so sliced library
    files preview inline.

  i18n
  - New slice.* and settings.{useSlicerApi,slicerCard,orcaslicerApiUrl,
    bambuStudioApiUrl,slicerApiUrlDescription,useSlicerApiDescription}
    + fileManager.noPermissionSlice keys across all 8 locales (en, de,
    fr, it, ja, pt-BR, zh-CN, zh-TW). English fully translated, German
    fully translated, the other six seeded with English fallbacks
    pending native translation.

  Tests
  - 10 backend integration tests in test_library_slice_api.py covering
    validation (404/400), happy-path enqueue, sidecar-down, 3MF
    embedded-settings fallback, STL no-fallback, and preset-error ->
    failed job paths.
  - New unit tests in test_slicer_api.py for the HTTP bridge.
  - 5 new SliceModal frontend tests covering preset gating, library +
    archive enqueue paths, error surface, and preset-load failure.
  - Existing SettingsPage tests adjusted: slicer dropdown asserts now
    switch to the Workflow tab first; added a beforeEach URL reset so
    one test's tab click doesn't bleed into sibling tests.

  Sidecar
  - New slicer-api/ folder is self-contained and optional. Two services
    (orca-slicer-api on 3003, bambu-studio-api on 3001 behind --profile
    bambu) build via Docker git-build-context from
    maziggy/orca-slicer-api@bambuddy/profile-resolver. The fork patches
    the OrcaSlicer CLI's profile compatibility quirks (inherits-chain
    resolver, from:User -> system rewrite, '# ' clone-prefix strip,
    sentinel-value strip) empirically required to slice real GUI
    exports without segfaulting the CLI.

  Docs
  - CHANGELOG entry under [0.2.4b1] - Unreleased Added.
  - README File Manager bullet for the new server-side Slice button.
  - bambuddy-website features.html: new card under "Configurable Slicer".
  - bambuddy-wiki: new page features/slicer-api.md + nav entry +
    features index card.

  Notes
  - Opt-in: with Use Slicer API off, the existing "open in desktop
    slicer via URI" flow is the default and unchanged.
  - 3MF inputs that segfault the CLI on --load-settings transparently
    retry with embedded settings; the resulting job carries
    used_embedded_settings: true.
  - Sliced files always export as .gcode.3mf so File Manager picks up
    the embedded thumbnail; file_type is set to "gcode" (blue badge).
2026-04-27 15:28:37 +02:00
maziggy 88b5f56eb2 fix: cancel = layer shift, stuck "1 problem", and dropped child-logger logs
Three bugs that surfaced together while debugging an H2D cancel:

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

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

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

  20 regression tests across:
  - backend/tests/unit/test_failure_reason_derivation.py (11)
  - backend/tests/unit/services/test_bambu_mqtt.py::TestHMSUserActionFiltering (4)
  - backend/tests/unit/test_trace.py::TestFilterMustBeAttachedToHandlerNotLogger (1)
  - frontend/src/__tests__/pages/PrintersPageBucketing.test.ts (5; includes
    the H2D-cancel-echo "FAILED + only unknown HMS" case)
2026-04-26 14:24:28 +02:00
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 12c01f029d Revert "feat(oidc): Azure Entra ID support — configurable email claim & verification + Remember Me persistent login (#1118)"
This reverts commit 50382006b3.
2026-04-25 11:05:32 +02:00
Sn0rrii 50382006b3 feat(oidc): Azure Entra ID support — configurable email claim & verification + Remember Me persistent login (#1118)
feat(oidc): add Azure Entra ID support with configurable email claim resolution
2026-04-25 11:02:06 +02:00
maziggy fcda728af4 feat(#1108): long-lived camera-stream tokens + fix(#1089) audit-pass tweaks
#1108 — Long-lived camera-stream tokens for HA / Frigate / kiosks. Camera-only
  V1, hard 365-day cap (no infinite tokens), pbkdf2 hashed at rest, plaintext
  shown to user exactly once on creation. New "Camera API Tokens" panel under
  Settings → API Keys with self-service create/revoke, styled confirm modal,
  admin "All users" view for leak triage. Auth path: /camera/stream tries the
  existing 60-min ephemeral table first, falls through to the long-lived path.
  Indexed lookup_prefix keeps verify O(1) per token.

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

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

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

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

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

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

  Permission gates unchanged; broadcaster has no FastAPI surface.

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

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

  Extract-zip is now rejected against any external folder (not just
  read-only) with a clear "extract on the mount and run Scan" message --
  the nested-subfolder creation path would need mkdir on the mount plus
  matching is_external LibraryFolder rows, which is a separate design.
  Scan already handles that shape.
2026-04-24 16:17:06 +02:00
maziggy 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 e8f252d2b8 Post work PR #701 2026-04-24 10:28:51 +02:00
lietschaend 91a3d391ff feat(virtual-printer): add Tailscale opt-out toggle (closes #701 point 3) (#1070)
* feat(virtual-printer): add Tailscale certificate provisioning
2026-04-24 09:59:09 +02:00
maziggy 0cf7a11f46 fix(#1105): recognise new H2C serial prefix "31B8B" for dual-nozzle detection
Bambu started shipping H2C units with a new serial prefix (`31B8B…`
  observed on a January 2026 unit) instead of the legacy `094…` shared by
  the H2D/H2C/H2S family. Two serial-prefix-driven paths — the K-profile
  edit branch in `kprofiles.py` and the delete-K-profile MQTT command in
  `bambu_mqtt.py::delete_kprofile` — were silently routing the new units
  through the single-nozzle format.

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

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

  - backend/app/api/routes/kprofiles.py: add "31B8B" to is_h2d tuple
  - backend/app/services/bambu_mqtt.py: same in delete_kprofile
  - backend/tests/unit/services/test_bambu_mqtt.py: regression test
    `test_h2c_new_prefix_uses_dual_nozzle_format`
2026-04-24 08:15:07 +02:00
maziggy bf511c54cd feat(#1008): archive auto-purge + dedicated archives:purge permission
Adds an archive counterpart to the library trash sweeper shipped in the
  previous commit. Unlike the library flow, archives are hard-deleted —
  print history is a decaying timeline, so there is no trash intermediate;
  download or favourite anything you want to keep first.

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

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

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

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

  - New `library:purge` permission (admin-only by default)
  - Nullable indexed `deleted_at` column on library_files; dialect-aware
    ALTER TABLE so the column actually gets added on PostgreSQL (raw
    DATETIME is SQLite-only syntax)
  - New `LibraryFile.active()` classmethod; every query site routed through
    it so trashed rows don't leak into listings, print dispatch, MakerWorld
    dedupe, or stats
  - Trash page: select-all + bulk restore/delete, per-row checkboxes, wider
    layout so datetime columns don't clip
  - Auto-purge: 24h throttle via `library_auto_purge_last_run` setting so
    the 15-minute sweeper cadence still runs the purge at most once per day
  - Save toast wired into every trash/auto-purge setting change
  - 17 new backend integration tests (service + routes + auto-purge throttle),
    8 new frontend tests, localised across all 8 UI languages
  - Wiki + website feature entries updated
2026-04-23 15:54:59 +02:00
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 991111327f fix(auth): setup 422'd on re-enable when admin user already exists
The SetupRequest Pydantic schema enforced password complexity unconditionally,
  but the route ignores admin_password entirely when an admin user already
  exists (the common case for re-enabling auth after it was disabled, or for
  LDAP deployments where the local admin is a placeholder). A legitimate
  existing password that predated the complexity rule — or the placeholder the
  form sends in LDAP mode — hit the Pydantic validator before the route body
  could decide it wasn't needed, surfacing as:

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

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

  Regression coverage in test_auth_api.py::TestAuthSetupAPI:
  - test_setup_weak_password_rejected_when_creating_new_admin — fresh setup
    with "NoSpecial1" → 400, "special character" in detail
  - test_setup_reenable_with_existing_admin_ignores_password — seeds an admin,
    POSTs /setup with a complexity-failing password → 200, admin_created=false
2026-04-22 18:32:29 +02:00
maziggy 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 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 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
Sn0rrii e958b10f75 fix(oidc): raise callback code/state max_length from 512 to 2048 (#1024)
Facebook and some other OAuth providers issue authorization codes that
exceed 512 characters. Pydantic rejected these with 422 string_too_long.
The OAuth spec defines no maximum code length; 2048 aligns with common
provider limits.

Also adds three integration tests to verify 512-char and 2048-char codes
are accepted while 2049-char codes are correctly rejected.
2026-04-19 08:10:56 +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