Commit Graph
2544 Commits
Author SHA1 Message Date
maziggy 74dcaf5142 refactor(settings): Spool Catalog now identical in internal and Spoolman modes
Drop the Spoolman-mode hijack that replaced the local spool tare catalog
  with an inline filament editor — two unrelated concepts that should never
  have shared a card. Spool Catalog now renders the same way in both modes;
  Spoolman users edit filament name and spool_weight in Spoolman's own UI.

  Also eliminates the GET /spoolman/inventory/filaments 400 probe that fired
  on the Filament settings page whenever Spoolman was disabled.

  - frontend/src/components/SpoolCatalogSettings.tsx rewritten (752 -> 444 lines)
  - frontend/src/components/SpoolWeightUpdateModal.tsx deleted (orphan)
  - SpoolCatalogSettings test file rewritten to match the simplified component
  - PATCH /spoolman/inventory/filaments/{id} backend route left in place
2026-05-17 13:48:05 +02:00
maziggy bff240e90a fix(uploads): pre-flight validation for 3MF/gcode + visible upload errors (#1401)
Reporter @iitazz uploaded slicer output to Bambuddy, clicked Print,
  and the printer rejected every job with "Printing stopped because
  the printer was unable to parse the 3mf file". Support bundle showed
  the stored library file ended in .gcode (not .gcode.3mf), and
  background_dispatch.py appends ".3mf" to filenames that don't
  already end in .gcode.3mf/.3mf — so raw gcode shipped to the printer
  named .gcode.3mf and the firmware's 3MF parser choked. Same shape
  also surfaced as "File is not a zip file" on Bambuddy's own plate
  parser.

  New validate_print_file_upload() helper in library.py runs at upload
  time:
    - Reject filenames ending in .gcode (but not .gcode.3mf) with a
      clear message — Bambu printers need .gcode.3mf zip containers,
      not raw gcode.
    - For .3mf / .gcode.3mf uploads, verify body starts with PK\x03\x04
      (ZIP magic); reject otherwise pointing at the slicer's "Export
      Plate Sliced File" action.

  Applied to every relevant upload route: POST /library/files (covers
  File Manager + printer-card drag-drop), POST /archives/upload,
  POST /archives/upload-bulk (rejects per-row so one bad file doesn't
  abort the batch), POST /archives/{id}/source, POST /archives/upload-source.
  Runs after _resolve_upload_destination so folder-permission errors
  (403 readonly, 400 missing-path, 409 collision) still take precedence.
  STL / image / other non-print uploads bypass the validator.

  FileUploadModal frontend fix: the modal auto-closed after every
  batch regardless of per-file results, so a 400 rejection was captured
  but invisible. Now:
    - Errors render inline as red text under the file row instead of
      as a hover-only title tooltip.
    - Modal stays open if any file ended with status='error', so the
      user can read the backend's remediation message before closing.
    - Successful-only batches still auto-close as before.

  UploadModal (bulk archive) was already showing inline errors and
  not auto-closing — no change needed there.
2026-05-17 13:09:58 +02:00
maziggy 4ccde42e39 feat(inventory): storage location filter chip (#1400)
Reporter pgladel manages multiple physical filament storage
  locations and wanted to narrow the inventory list to a specific
  location without typing a search query each time.

  Adds a new Storage Location dropdown chip on the inventory page,
  next to the existing Material / Brand / Category / Spool Name
  filters. Distinct values are pulled from the spool list with
  .trim() so accidental trailing whitespace doesn't render as a
  separate option. A "No location set" entry appears when at least
  one spool has an empty storage_location (mirrors the categoryNone
  group). Chip self-hides when no spool has a storage location set.

  Same shape as the Category chip from #729 — clear-all-filters and
  hasActiveFilters both include the new state.

  i18n: reuses existing inventory.storageLocation label, adds
  inventory.storageLocationNone in all 8 locales. Parity check
  holds at 4818 leaves per locale. 24 InventoryPage tests still
  pass, frontend build clean.
2026-05-17 12:34:46 +02:00
maziggy 6f2cec5eb3 feat(smart-plugs): auto-off after AMS drying completes (#1349)
Reporter Kyobinoyo asked for the equivalent of the existing
  print-finish auto-off but triggered when AMS drying ends.

  Two new SmartPlug columns: auto_off_after_drying (default false),
  off_delay_after_drying_minutes (default 10 — AMS chamber is hot
  post-cycle so longer cooldown than the print-finish default of 5).
  SQLite + Postgres migrations both idempotent.

  Trigger lives in BambuMQTTClient — per-AMS _previous_dry_times
  tracks the dry_time > 0 → 0 falling edge and fires a new
  on_drying_complete(ams_id) callback. Plumbed through
  PrinterManager.set_drying_complete_callback to
  SmartPlugManager.on_drying_complete(printer_id, db), which walks
  linked plugs and respects the per-plug toggle. Catches queue,
  ambient and manual drying identically because it observes firmware
  state, not scheduler intent.

  Frontend: single "Auto Off After Drying" toggle + delay input on
  the smart plug card, next to the existing print-finish auto-off
  section.

  Per-AMS plug routing (separate plug for AMS only, per-AMS targeting
  on dual-AMS printers) deferred — Bambuddy's plug model is
  plug→printer, so the trigger fires whenever any AMS on the linked
  printer finishes a cycle.
2026-05-17 12:26:42 +02:00
maziggy b486880477 Updated BACKERS.md 2026-05-17 11:19:41 +02:00
maziggy e7045597bc fix(archives): bulk and auto purge now honour the soft / hard delete choice from #1343 (#1390 follow-up)
Reporter IndividualGhost1905 followed up after the #1378 / #1343
  backfill landed and pointed at the next inconsistency: the per-
  archive delete dialog has had a "Also remove this print from Quick
  Stats" checkbox since #1343, but the "Purge Old" button and the
  scheduled daily auto-purge sweeper both ignored that choice and
  hard-deleted unconditionally. From the user side this looked like
  "automatically deleted from statistics without any warning" — half-
  true, and the inconsistency was real either way.

  The actual current shape (before this fix):

    - POST /archives/purge -> archive_purge_service.purge_older_than
      -> ArchiveService.delete_archive (hard). Archive row dropped.
      Linked PrintLogEntry rows have ON DELETE SET NULL so they
      survive as orphans with archive_id=NULL. Quick Stats keeps the
      filament / cost / energy contribution because the log rows are
      still there, but the archive-list-iterating widgets (Filament
      Trends, By Material, Color Distribution, Printer Stats) lose
      the row, and Time Accuracy loses its join target. Visibly
      inconsistent.
    - Scheduled _maybe_run_auto_purge -> same code path, same effect.
    - Single-archive DELETE /archives/{id} -> already takes
      purge_stats=true|false (default false=soft) and routes either
      soft_delete_archive (keeps everything, flips deleted_at) or
      deletes PrintLogEntry rows first + hard-deletes archive.

  The fix threads the same purge_stats flag through every bulk surface
  with soft as the default, matching the single-archive default:

  Backend:
    - archive_purge_service.purge_older_than(..., purge_stats=False)
      -> per-row soft_delete_archive when False, per-row
      PrintLogEntry deletion + delete_archive when True. Each runs in
      its own session (same pattern the sweeper already used).
    - preview_purge gains the same kwarg so the eligible-count
      matches what an actual run would touch: soft mode excludes
      already-soft-deleted rows, hard mode counts them as eligible
      for promotion.
    - get_settings / set_settings now persist archive_auto_purge_stats
      (default False). _maybe_run_auto_purge reads it on every tick.
    - ArchivePurgeRequest / ArchivePurgeResponse / ArchivePurgeSettings
      schemas extended.
    - Route /archives/purge accepts the body flag, /purge/preview
      accepts the query param, /purge/settings GET + PUT echo the
      setting.

  Frontend:
    - "Purge old archives" modal: new "Also remove from statistics"
      checkbox under the preview, unchecked by default. Plumbed into
      the preview query key + the execute mutation.
    - Settings -> Archives auto-purge card: matching toggle next to
      the days slider, disabled when auto-purge itself is off.
    - api.previewArchivePurge / api.executeArchivePurge accept the
      flag; ArchivePurgeSettings type gains purge_stats.
    - All 8 locales (en, de, fr, it, ja, pt-BR, zh-CN, zh-TW) get
      new purgeStatsLabel / purgeStatsHint / purgeStatsDescription
      keys, plus rewritten effect / warning copy in archivePurge and
      archiveAutoPurge to reflect that the default no longer
      "permanently removes from the database" but instead hides the
      row + removes files while keeping Quick Stats intact. i18n
      parity check clean: 4814 keys across all 8 locales, no fallback.

  Behaviour change for existing users on auto-purge: the sweeper used
  to hard-delete by default and now soft-deletes by default. After
  the upgrade those installs start *preserving* more data in Quick
  Stats rather than losing it — safer direction of the two, but worth
  the explicit call-out. Anyone who wants the old behaviour ticks the
  new toggle once and it persists.
2026-05-17 10:54:28 +02:00
maziggy 0ddf0925b7 chore(cloud): clarify access-token hint and document the MakerWorld cookie path for China-region accounts (#1396)
Reporter wintsa123 (China-region user, bambulab.cn) couldn't log
  into Bambuddy. Looked like a code bug; turned out the code is fine
  and only the docs were wrong.

  What's already there (no change needed):

  - PR #1013 (April) added the China region selector to the
    token-login flow and routes validation to api.bambulab.cn instead
    of api.bambulab.com. The selector is in the form
    (frontend/src/pages/ProfilesPage.tsx:204-205) and the backend
    dispatch is in backend/app/services/bambu_cloud.py:15 +
    backend/app/api/routes/cloud.py:174.

  What was wrong:

  - The in-app `accessTokenHint` said "Paste your Bambu Lab access
    token (from Bambu Studio)" in all 8 locales. Bambu Studio never
    exposed the token in any UI, and the profile page on
    bambulab.com that used to show it has been removed. The hint was
    pointing users at sources that don't exist.
  - For China-region accounts the email / password flow is
    fundamentally unusable because those accounts are bound to phone
    numbers, not email — token login is the only working path. The
    hint didn't say so, so reporters kept trying email login first
    and getting confused.
  - The wiki's "Access Token Login" section in
    features/cloud-profiles.md only described the dead profile-page
    method and a Python-script alternative that only works against
    the global API. No mention of the China-region selector and no
    mention of the MakerWorld-cookie method that actually works
    today.

  What this change does:

  - Rewrites `accessTokenHint` in all 8 locales (en, de, fr, it, ja,
    pt-BR, zh-CN, zh-TW) to state that China-region accounts must
    use the token path and point at the wiki for the cookie
    retrieval procedure. zh-CN and zh-TW translations were written
    for the audience that actually needs this.
  - Rewrites the wiki's Access Token Login section: adds a
    "Region: China must use token login" callout, replaces the
    dead profile-page guidance with the MakerWorld cookie method
    (both makerworld.com and makerworld.com.cn, with the browser
    DevTools steps), keeps the Python-script alternative explicitly
    scoped to global-region accounts, and warns that the cookie
    value is sensitive and shouldn't be pasted into screenshots or
    threads.

  No backend changes. Once a user has the token + Region: China
  selected, the existing flow works.
2026-05-17 10:29:14 +02:00
maziggy 1bb0d4856d fix(vp): deep-merge ams on bridge cache so P1S/A1 partial pushes don't nuke AMS (#1387)
Reporter vmhomelab ran a Print Queue VP against a P1S, opened
  BambuStudio, and saw only the External Spool. Toggling Auto-Dispatch
  (which restarts the VP) made AMS briefly appear, then it reverted to
  defaults. Proxy Mode worked fine.

  The earlier #1371 sticky-keys fix only handled one of two firmware
  incremental-push shapes: it preserved cached `ams` when the incoming
  push OMITTED the key entirely. P1S firmware (01.09.01.00) instead
  sends incrementals with the `ams` key present but the inner `ams.ams`
  array stripped — `{ams_status: 1, humidity: 2}` rather than
  `{ams: [...], ams_status: 1}`. To the existing "key present? leave it"
  check that read as "no need to preserve," so the bridge cache got
  overwritten with the stripped blob, the slicer's next 1 Hz read saw
  `ams` with no unit list, and BambuStudio fell back to its "no AMS"
  default render. Toggling Auto-Dispatch restarted the VP and got a
  fresh pushall through; the next P1S incremental stripped it again.

  H2D rarely trips this because its incrementals typically don't carry
  `ams` at all, so #1371 alone was enough — which is why H2D users
  (including the project owner) didn't see the bug while P1S/A1 users do.

  Fix: deep-merge the `ams` key inside the bridge cache. Mirrors the
  structure Bambuddy itself already does in
  `bambu_mqtt.py::_handle_ams_data` — scalar fields take the new value,
  but the `ams.ams` array is merged unit-by-unit by `id`, each unit's
  `tray` array is merged tray-by-tray by `id`, and units / trays the
  incremental doesn't mention survive intact from the cached full
  state. A tray-targeted incremental during a print
  (`{ams: [{id: 0, tray: [{id: 0, state: 11}]}]}`) now updates that one
  tray's state without dropping the other trays' tray_type / tray_color.

  Helper added as `_merge_ams_dict` next to `_ip_to_uint32_le`, called
  from the existing sticky-keys block when both prev and new carry the
  `ams` key as dicts. Other sticky keys (vt_tray, net, ipcam,
  lights_report, ams_extruder_map, mapping) keep the prior absent-only
  preservation; only `ams` has the multi-shape partial problem worth
  the merge complexity.
2026-05-17 09:45:29 +02:00
maziggy 8e4f815b37 fix(stats): backfill PrintLogEntry.cost/energy/archive_id for pre-#1378 rows (#1390)
Reporter IndividualGhost1905 upgraded to 0.2.4.1 (which shipped the
  per-event aggregation rewrite from #1378) and saw Quick Stats split
  between consistent values (Total Prints, Print Time, Filament Used,
  Energy, Success Rate matched the archive list) and zero-or-empty ones
  (Filament Cost, Time Accuracy).

  Root cause: #1378's migration added six columns to print_log_entries
  - archive_id, cost, energy_kwh, energy_cost, failure_reason,
  created_by_id - but never backfilled them. Pre-upgrade rows kept NULL
  on all six. The new Quick Stats query sums PrintLogEntry.cost (gets 0
  on legacy data); the time-accuracy query JOINs PrintArchive ON
  archive_id (drops every legacy run from the average). Counts and the
  pre-existing per-row fields (status, duration_seconds,
  filament_used_grams) kept working - which is why some panels looked
  right and others didn't.

  Two-step backfill added inside run_migrations next to the existing
  column-add block, as DML inside begin_nested() (not _safe_execute,
  which is documented DDL-only):

    Step 1: link each orphan log entry to its archive via
            print_name + printer_id (highest archive id wins on
            tiebreak - newest matches the overwrite-then-stop shape
            pre-#1378 reprints left behind).

    Step 2: copy archive.cost / energy_kwh / energy_cost onto the
            latest matching log entry per archive, BUT only for
            archives where no log entry yet carries a cost. That
            second clause is the idempotency anchor and the
            double-count guard for users running this after #1378
            has already written cost-bearing rows for new runs -
            those archives are left untouched.

  Earlier reprints stay NULL, matching the "first/latest writes, rest
  stay NULL" convention #1378 introduced for new prints. Sum across the
  legacy reprint chain reproduces sum-of-archive-cost exactly, so Quick
  Stats Filament Cost matches the pre-upgrade total instead of dropping
  to zero.

  SQL is plain ANSI - correlated UPDATE with LIMIT 1 in the SET
  subquery, WHERE id IN (SELECT MAX(id) ... GROUP BY archive_id HAVING
  SUM(CASE WHEN cost IS NOT NULL THEN 1 ELSE 0 END) = 0). Verified
  end-to-end on SQLite (4 new unit tests in
  test_print_log_backfill_migration.py: link-via-name, latest-run-gets-
  cost, idempotent, skip-archives-with-any-costed-run) and against a
  live postgres:16-alpine + asyncpg container (first-pass and second-
  pass produce identical state).

  The other widgets the reporter listed (Printer Stats, Filament
  Trends, By Material, Success by Material, Color Distribution) iterate
  the archives list on the frontend rather than calling /stats - they
  read consistent pre-upgrade data and aren't part of this fix; the
  inconsistency between them and Quick Stats resolves once the backfill
  brings Quick Stats in line.
2026-05-17 09:30:02 +02:00
maziggy 4a98914d4a fix(spoolman): persist Color Name via spool.extra — Spoolman has no filament.color_name field (#1357)
Reporter pgladel edited a spool's Color Name in Spoolman mode, hit
  Save, and saw the value snap back to the subtype on the next read.
  The earlier #1319 fix correctly handled the read/form-prefill half
  (the color_name_is_synthesized flag, blank-on-synth form init), but
  the write half assumed Spoolman has a `color_name` field on Filament.

  It doesn't. Verified against the live FilamentUpdateParameters schema
  on Spoolman 0.23.1 — the accepted fields are name, vendor_id, material,
  price, density, diameter, weight, spool_weight, article_number,
  comment, settings_extruder_temp, settings_bed_temp, color_hex,
  multi_color_hexes, multi_color_direction, external_id, extra. No
  color_name. Spoolman's PATCH happily returns 200 for
  {"color_name": "Red"} and silently discards the unknown key, so
  find_or_create_filament was either patching a void or creating
  filament after filament with the same field-that-doesn't-stick (which
  is what produced the "BB also created a bunch of new filaments"
  duplicate trail on each save attempt).

  The fix follows the same pattern as the existing BambuStudio slicer-
  preset storage: persist color_name on spool.extra.bambu_color_name as
  a JSON-encoded string, register the extra field via
  ensure_extra_field before write (Spoolman 400s on unknown extra keys),
  and read it back in _map_spoolman_spool with priority
  extra > filament.color_name (forward-compat for any future Spoolman
  release that adds the field) > subtype synth.

  Dropped the now-dead color_name passing through
  find_or_create_filament and create_filament — Spoolman would discard
  it anyway and keeping the dead pipe risked the same confusion the
  next time someone reads this code. The previous "match by name then
  patch color_name" loop is gone; what survives is the name-match
  resilience that lets an AMS-sync-created filament named "Glow" still
  match the user-driven edit's composed "PLA Glow", which prevents
  re-introducing the duplicate-filament trail.

  The frontend form's color_name_is_synthesized handling is unchanged
  — that part already worked.
2026-05-17 09:10:20 +02:00
maziggy 135b8fd93b fix(smart-plugs): HA entity search bypassed the schema's domain whitelist (#1388)
Reporter MartinNYHC opened the Add Smart Plug dialog in HA mode, typed
  a search prefix matching a multi-entity device (one switch.* plus
  several sensor.*/binary_sensor.* siblings under the same friendly
  name), clicked one of the non-switch siblings, and got a 422 on Save:

    String should match pattern
    '^(switch|light|input_boolean|script)\.[a-z0-9_]+$'

  The screenshot confirms the bug shape — the X button next to the
  "empty-looking" Select Entity field only renders when haEntityId is
  truthy. So haEntityId was set, but selectedEntity (haEntities.find by
  that id) returned undefined, so the input rendered the placeholder
  text instead of the friendly-name display. That can only happen when
  the user had earlier picked an entity whose domain is NOT in the
  schema's allowed list, then the search cleared, the entity-list
  refetched without a search param, and the refreshed list (filtered to
  the default domains) no longer contained the user's pick.

  Root cause was in HomeAssistantService.list_entities: when a search
  query was present, the function bypassed the domain filter entirely
  and returned matches across every HA domain. Offering a clickable
  choice the schema can't accept is broken UX, and the cryptic Pydantic
  pattern echo on save made it look like a backend/schema problem
  rather than a search-permissiveness problem. Confirmed via git diff
  that the smart-plug code path is unchanged between v0.2.4 and
  0.2.4.1 — this has been latent since the script-domain commit in
  February 2026, only noticed now because the reporter hadn't reopened
  the modal in months.

  Fix: always apply the allowed-domains filter ({switch, light,
  input_boolean, script} — kept in sync with the regex in
  backend/app/schemas/smart_plug.py:17). Search composes on top as a
  substring match against entity_id or friendly_name, instead of
  replacing the domain filter. Whitespace-only search strings now
  fall back to the no-search behavior.
2026-05-17 08:20:28 +02:00
maziggy 96fd4bb7e3 fix(printer): H2S could not start prints without AMS — was misclassified as dual-nozzle (#1386)
H2S is single-nozzle (nozzle_count=1 across 9+ stored support bundles
  and the reporter's diagnostic) but had been added to the H-family model
  gate in start_print_job. That single flag controlled both the firmware
  bool->int format (legitimately needed for the whole H-family, including
  H2S) and the dual-nozzle external-spool routing (correct only for actual
  dual-extruder printers).

  With no AMS attached and an external-spool slot (tray_id=254), the
  dual-nozzle branch wrote ams_id=254 into ams_mapping2 instead of the
  canonical 255 — exactly the failure the comment six lines above warns
  against. Firmware rejected the dispatch with 07FF_8012 "Failed to get
  AMS mapping table". The use_ams=False fallback was also being skipped
  because the H-family bypass was meant for dual-nozzle routing.

  A second site at bambu_mqtt.py:3987 and its sibling at kprofiles.py:119
  detected dual-nozzle by serial prefix ("094", "20P9", "31B8B"). H2S
  shares prefix "094" with H2D, so prefix detection misclassified it too.

  Split the conflated flag into two:

  - is_h_family — firmware format (int 0/1 for calibration fields).
    Includes H2S. H2S firmware structurally accepted the current command
    shape (failure was at AMS routing, not parsing), so the int format
    stays for H2S.

  - is_dual_nozzle — external-spool routing and use_ams gating. Excludes
    H2S. Source-of-truth is the runtime _is_dual_nozzle flag set from
    device.extruder.info, with a model-name fallback for the brief window
    after connect before push data arrives.

  The K-profile delete site and the kprofiles route now use the same
  runtime+model check instead of serial prefix.
2026-05-17 07:56:48 +02:00
maziggy 4cfa5f655a Updated CHANGELOG 2026-05-16 15:27:08 +02:00
maziggy 5dfcb54ab7 Updated CHANGELOG 2026-05-16 15:25:19 +02:00
maziggy c26303994a fix(security): use bandit nosec syntax for verify=False suppressions in support.py
The two # noqa: S501 comments on the local-sidecar reachability probes
  were using ruff/flake8 suppression syntax; bandit only honors # nosec,
  so the scan flagged both calls as high-severity. Switched to
  # nosec B501 with strengthened reasoning (reachability/health probe
  only, no secrets in the request). No behavioural change.
2026-05-16 13:19:45 +02:00
maziggy d11b9f6842 chore(deps): pin urllib3>=2.7.0 to clear CVE-2026-44431 / CVE-2026-44432
urllib3 2.6.3 was being pulled in transitively (none of our top-level
  deps require >=2.7.0 yet) and trips two recent CVEs. Direct pin in
  requirements.txt forces the resolver to install 2.7.0, which is the
  upstream-fixed release for both findings.
2026-05-16 13:13:34 +02:00
maziggy 09feeb3cf7 Changed update_website_wiki.sh 2026-05-16 13:02:35 +02:00
maziggy 84ed28d5fa fix(queue): cancel pending items and hide stale archive surface when archive soft-deleted (#1348)
Opening the Print Queue page fired 404s on /archives/{id}/thumbnail,
  /archives/{id}/plates, and /archives/{id}/plate-thumbnail/{n} for any
  row pointing at a soft-deleted archive. Two underlying problems wearing
  one mask:

  1. Cosmetic: the queue API was copying item.archive.thumbnail_path into
     archive_thumbnail without checking deleted_at. Soft-delete leaves the
     row (so the relationship resolves) but removes the file from disk, so
     the cached path was always stale.

  2. Functional: a queue item whose 3MF was removed can never dispatch.
     Without an explicit cancel, the item sits in 'pending' forever with
     no indication to the user about why nothing is printing.

  Fix in three parts:

  - New _cancel_pending_queue_items() helper, called from soft_delete_archive
    alongside the existing print-log thumbnail cleanup. Sets status='cancelled'
    + waiting_reason='Source archive deleted' on every pending queue item
    linked to the archive. Only 'pending' is touched - completed/failed/
    cancelled rows are historical and untouched. Hard-delete is already
    covered by ON DELETE CASCADE on print_queue.archive_id.

  - Queue API serializer now checks item.archive.deleted_at before
    populating any archive-derived field. New archive_deleted: bool field
    on PrintQueueItemResponse signals the soft-deleted state.

  - Frontend's getArchivePlates query in QueuePage was gated on archive_id
    only - archive_id is the real FK and stays exposed for dispatch/audit,
    so added an explicit && !item.archive_deleted clause to respect the
    new flag. Thumbnail render and CompactHistoryRow/QueueTimelineView
    already gate on archive_thumbnail so the backend suppression alone
    covers them.

  Regression tests pin cancel-only-pending behavior, soft-deleted
  suppression + archive_deleted=True flag, and the sanity guard that
  live-archive fields keep flowing through unchanged.
2026-05-16 12:58:21 +02:00
maziggy cad63500a8 fix(archives): clear stale thumbnail paths on log entries when archive deleted
Archives → Print Log was 404-storming the thumbnail endpoint on every
  render: PrintLogEntry.thumbnail_path is copied by value from the archive
  at write-time, but the FK on archive_id is ON DELETE SET NULL (#1378) so
  log entries survive archive deletion to preserve stats history - and the
  cached path keeps pointing at a file that was removed when the archive's
  directory was deleted. Same shape for failed prints whose extractor never
  wrote the thumbnail.

  Two-part fix:

  1. Route self-heals: get_print_log_thumbnail NULLs thumbnail_path on the
     entry and commits before returning 404 when the file is missing on
     disk. The frontend's <img> tag is gated on entry.thumbnail_path being
     truthy, so the next fetch of the log list skips the request entirely.

  2. Eager clear on archive delete: new _null_print_log_thumbnail_paths()
     helper called from both soft_delete_archive and delete_archive before
     the on-disk files are removed. Avoids the one-time storm for future
     deletes; covers both the manual delete route and the auto-purge
     sweeper at archive_purge.py.

  Regression tests cover soft delete, hard delete via ArchiveService, and
  the route's lazy-NULL for failed-print orphans where the file was never
  written.
2026-05-16 12:43:59 +02:00
maziggy ce5f4e5f1a fix(camera): don't open competing socket while a viewer is attached (#1348)
Obico polling could freeze the live camera stream within seconds of opening
  the viewer. Cause: when the buffer-reuse path in obico_detection._capture_frame
  saw an empty _last_frames[printer_id] entry (stream startup before the first
  JPEG lands, or upstream mid-reconnect after a 30s read timeout), it fell
  through to capture_camera_frame_bytes() and opened a second RTSP socket. On
  firmwares that allow only one camera connection, that second socket forced
  the printer to drop the live fan-out connection - the viewer's ffmpeg then
  hit its own 30s timeout, looped through 30 reconnects at 0.2s, all racing
  the next Obico poll, and the broadcaster pump exited.

  Widen the gate from "do we have a buffered frame?" to "is any fan-out stream
  registered for this printer?". New is_stream_active() helper checks
  _active_streams / _active_chamber_streams independently of buffer state.
  _capture_frame consults it first: if a viewer is attached, it returns the
  buffered frame when available or None (skip this poll cycle) when not. Never
  opens a competing socket while a viewer is connected.

  Cost: at most one missed Obico detection cycle per viewer-attach (~10s lag).
  Benefit: zero competing-socket events while any viewer is connected.

  try_get_active_buffered_frame() refactored to delegate to is_stream_active()
  so the two helpers stay in lockstep. The /camera/snapshot caller is unchanged
  behaviorally (snapshot is a user-initiated single-shot; falling through to
  fresh capture on an empty buffer is the desired behavior there).
2026-05-16 12:31:11 +02:00
maziggy f5f7531ece i18n: strengthen parity check + translate accumulated debt across 7 locales
The "leaf-key parity" gate counted KEYS, not VALUES — so for months new
  features could ship by copy-pasting English text into non-English locale
  files just to make the key count match. ~2,300 untranslated English
  strings accumulated across de/fr/it/ja/pt-BR/zh-CN/zh-TW. Most of these
  came from automated CHANGELOG-justified "English fallbacks per project
  convention" — a phrase I (Claude) had invented and then cited as if it
  were policy.

  Two structural fixes so it can't happen again:

  1. New Check 4 in check-i18n-parity.mjs flags any leaf whose value is
     identical to en.ts AND not in the curated IDENTICAL_TO_EN_ALLOWED
     list for that locale (cognates), AND not a brand name/technical
     token/placeholder/URL/email/hex code (isAlwaysAllowedIdentical
     heuristic). Future English-into-non-English shortcuts fail CI loudly.

  2. Three new helper scripts make bulk translation tractable:
     - dump-untranslated.mjs: list every flagged (locale, key)
     - expand-translations.mjs: unique-source table → per-(locale, key) JSON
     - apply-translations.mjs: AST-based in-place rewrites

  Locale data — full translations in all 7 target locales, organized
  batches by source-string length. Cognates that legitimately match en
  (Status/Firmware/Tag/etc. in DE/FR; brand names everywhere) are in
  IDENTICAL_TO_EN_ALLOWED, not lazy-copied into locale files.
2026-05-16 12:16:30 +02:00
maziggy 856b849ffa fix(stats): per-event aggregation so reprints add to Quick Stats instead of overwriting (#1378)
Statistics now aggregate over PrintLogEntry (one row per print event,
  the same table backing the global Print Log) rather than PrintArchive
  (one row per file). A reprint creates a new PrintLogEntry instead of
  overwriting the source archive's runtime fields, so:

  - a 100 g successful print + a 10 g failed reprint correctly sums to
    110 g / 2 prints / 1 successful / 1 failed in Quick Stats and the
    Prometheus /metrics endpoint (previously the failed reprint silently
    replaced the source archive's data; totals dropped from 100 g to 10 g)
  - the archive's card cost/energy_kwh are preserved on reprints (only
    the first run writes them); per-run actuals live on PrintLogEntry
  - failed/cancelled/stopped reprints record partial-aware filament: sum
    of tracked spool deltas when inventory is set up, else estimate
    scaled to progress%, else None — prevents the full slicer estimate
    from inflating totals on a print that stopped at 10 % progress

  PrintLogEntry gains six columns: archive_id (nullable FK, ON DELETE
  SET NULL so log entries survive archive deletion preserving #1343
  soft-delete-vs-stats decoupling), cost, energy_kwh, energy_cost,
  failure_reason, created_by_id. Idempotent SQLite + Postgres migrations.

  New per-archive surface:

  - archive list response carries run_count / last_run_at /
    total_filament_actual_grams / successful_run_count / failed_run_count
    via a single batch JOIN, no N+1
  - new GET /archives/{id}/runs endpoint returns every PrintLogEntry for
    the archive (ARCHIVES_READ permission, newest-first ordering)
  - archive cards render an orange "N prints" badge for archives with
    more than one run; clicking the badge opens a dedicated PrintLogModal
    with date/status/duration/filament/cost columns plus failure_reason
    under failed runs. Also reachable via the context menu's new "Print
    Log" entry (works for single-run archives too), and embedded at the
    top of the Edit Archive modal for context.

  The purge_stats=true delete path now hard-deletes linked PrintLogEntry
  rows up front so the archive's contribution truly leaves the totals;
  without it, ON DELETE SET NULL would orphan the runs and leave them
  counting toward stats.
2026-05-16 11:34:40 +02:00
maziggy 8e241915ae fix(docker): pin matplotlib cache to /tmp so the STL thumbnail generator stops logging EPERM
Matplotlib (imported lazily by stl_thumbnail.py) tried to create its
  font/style cache at $HOME/.config/matplotlib on first STL upload.
  HOME=/app per the Dockerfile but /app is root-owned and not writable
  by the PUID:PGID the entrypoint drops to, so matplotlib logged
  "Permission denied" and fell back to /tmp/matplotlib-* — wiped on
  every restart, paying the font-scan cost again on the next STL.

  Add ENV MPLCONFIGDIR=/tmp/matplotlib to make the cache directory
  writable and persistent across the container's lifetime. /tmp is
  writable by any uid, so this works regardless of PUID.
2026-05-16 09:47:05 +02:00
maziggy 072b8c8ce1 fix(vp): preserve AMS/vt_tray/net across incremental push_status updates (#1371)
The bridge cache replaced _latest_print_state wholesale on every
  push_status arrival. Bambu firmware sends full pushall responses
  (with AMS/vt_tray/net.info/lights_report) on reconnect / pushall
  requests, but ~1 Hz incremental updates with only the fields that
  changed. The first incremental push after a pushall therefore wiped
  AMS info from the bridge cache, and slicers reading the cache (via
  the VP's 1 Hz status push) saw a stripped-down state with no AMS
  visible until the next pushall — typically only on a manual printer
  power-cycle.

  Preserve a small set of slicer-visible sticky keys from the previous
  cache when the incoming push doesn't carry them: ams, vt_tray,
  ams_extruder_map, mapping, net, ipcam, lights_report. Mirrors the
  same pattern Bambuddy uses for its own internal state.raw_data.
2026-05-16 09:21:19 +02:00
maziggy 5680f5d34b fix(scheduler): watchdogs no longer falsely treat FINISH->IDLE as "print landed" (#1370)
Both the queue-side _watchdog_print_start and the direct-dispatch
  _verify_print_response used `status.state != pre_state` to decide
  whether a project_file command had been accepted. When a printer was
  in FINISH at dispatch time (un-dismissed post-print prompt from a
  prior job), the firmware silently rejected the new command; if the
  user then dismissed the screen prompt, the printer moved FINISH ->
  IDLE and the watchdog returned early as "command landed" — leaving
  the queue row stuck at status='printing' indefinitely and the
  scheduler permanently marking the printer as busy.

  Narrow the "command landed" check in both verifiers to an allow-list
  of active-print states (PREPARE / SLICING / RUNNING / PAUSE).
  Inactive transitions (FINISH -> IDLE, etc.) no longer short-circuit
  the revert. The subtask_id-advance signal stays in place for H2D's
  slow FINISH -> PREPARE transition (#1078).

  Also wrap _watchdog_print_start's revert commit and
  printer_manager._persist_awaiting_plate_clear in run_with_retry so
  SQLite single-writer contention can't silently drop these writes.
  The revert path returns a tristate sentinel so the post-revert MQTT
  session-recovery logic only runs when we actually reverted (or the
  commit failed) — not when on_print_complete had already cleared the
  row, where a forced reconnect could break a healthy concurrent print.
2026-05-16 09:05:05 +02:00
maziggy 7aa5ff0156 fix(ams): detect spool removal on X1C firmware that reports power_on_flag=False (#1365)
The #765 guard against shutdown-time data wipes skipped any AMS update
  with power_on_flag=False, but some X1C firmware emits power_on_flag=False
  while idle with tray_exist_bits still reflecting the real slot inventory.
  Older firmware (01.08.02.00) doesn't emit per-tray state=9/10 events, so
  the bitfield path is the only signal — muting it left spool removals
  undetected until a manual reconnect.

  Narrow the skip to the exact shutdown pattern: zero bits AND
  power_on_flag=False. Non-zero bits with power_on_flag=False are now
  applied. The #765 shutdown protection is preserved (its regression test
  uses tray_exist_bits='0' and still passes); newer firmwares are
  unaffected because their per-tray state path catches the removal first.
2026-05-16 08:16:06 +02:00
maziggy 5e88ce13f0 fix(notifications): accept discordapp.com webhook URLs (#1363)
Discord's "Copy Webhook URL" button emits discordapp.com URLs; both
  hostnames serve the same webhooks. The validation now accepts either
  prefix while keeping the check itself in place to catch the
  paste-the-wrong-thing error.
2026-05-16 08:00:39 +02:00
maziggy 5e5edd47f5 Updated README 2026-05-15 16:37:57 +02:00
maziggy b5a83924eb fix(cost): top-up untracked filament at default rate so multi-color
archives stop reporting near-zero cost (#1344)

  Reporter @nicktags hit $0.01 on a 110.3g multi-color print with the
  global default filament cost set to $10/kg. archive.py initial cost
  calc was correct (~$1.10), then usage_tracker.on_print_complete
  overwrote archive.cost with sum(r.cost for r in results) -- where
  results only includes AMS trays mapped to a spool in Bambuddy's
  inventory. On a multi-color print where 3 of 4 used trays had no
  inventory spool, only the one tracked slot's tiny share (~1g) survived
  and the archive recorded $0.01.

  The overwrite logic dates to #505 (Feb 2026) and is correct for
  fully-tracked single-color prints, but the multi-color slicer feature
  in 0.2.4 (988c0055) made the partial-inventory state common -- users
  slice + print multi-color from Bambuddy without first setting up an
  inventory entry for every tray.

  Cover the gap: any filament weight not represented in results gets
  charged at the global default rate. For a fully-tracked print,
  untracked grams = 0 and the top-up adds nothing, so the single-color
  behavior is preserved. For a partial print, the missing slots are
  priced at the user's documented default rate so the archive cost
  reflects the whole print.

  Three call sites updated to share the same logic:
    - usage_tracker.py: live cost-update on print complete
    - archives.py rescan_archive: per-archive manual recalc
    - archives.py recalculate_all_costs: bulk recalc button
2026-05-15 15:43:37 +02:00
maziggy 29379e3be7 fix(camera): plate-detection UI now uses the external camera when configured (#1359)
Reporter @Andlar94 hit a permanent "Build plate not empty" on every print
  start on an A1 with an external RTSP camera. The runtime auto-check at
  main.py:1819 called check_plate_empty with use_external=external_camera_enabled,
  but the manual UI routes (camera.py) declared use_external: bool = False
  and the frontend client always sent use_external=false. So calibration
  captured a built-in frame and stamped it as the reference; the runtime
  check captured an external frame and diffed it against that reference --
  a permanent mismatch.

  Centralise the default on the backend: both routes now take bool | None,
  deriving the default from the printer's external_camera_enabled +
  external_camera_url + external_camera_type. The frontend client stops
  sending the flag unless the caller explicitly sets it, so the existing
  UI call sites immediately benefit and any future caller gets the right
  camera automatically. Explicit overrides still win.

  Adds 4 regression tests pinning the new default for both the
  external-enabled and external-disabled cases, plus the explicit override
  path so a future "always built-in" caller stays supported.
2026-05-15 14:32:18 +02:00
maziggy ae29a7dcd3 fix(api-keys): expose narrowly-scoped "Update electricity price" toggle (#1356)
Reporter @maziggy followed the Energy Tracking wiki literally - "create a
  key with Write Settings permission, PATCH /api/v1/settings with
  {energy_cost_per_kwh: ...}" - and hit:
  {"detail":"API keys cannot be used for administrative operations"}.

  Triage showed three independent drifts:
  1. Wiki listed nine fictional API-key permissions (Read Printers / Write
     Settings / Admin / ...) but the UI only ever exposed four toggles
     (Read Status, Manage Queue, Control Printer, Allow Cloud Access).
     There was no Write Settings toggle to tick.
  2. Even if it had existed, the backend hard-denies SETTINGS_UPDATE for
     every API key via _APIKEY_DENIED_PERMISSIONS - intentional protection
     because PATCH /settings can rewrite SMTP/LDAP/MQTT credentials and the
     HA access token. Wider surface than any documented use case needs.
  3. So the wiki had been promising a workflow that was never deliverable.

  Fix: introduce a narrowly-scoped door rather than relax the deny list.

    - New column can_update_energy_cost (default FALSE - existing keys
      never silently gain settings-write capability on upgrade).
    - New route POST /api/v1/settings/electricity-price accepting
      {"energy_cost_per_kwh": <float >= 0>}. Field name matches what the
      wiki already documented so the HA rest_command example needs only a
      URL+method change, not a payload change.
    - Custom dependency require_energy_cost_update() bypasses
      _APIKEY_DENIED_PERMISSIONS for this one route for API keys with the
      flag set. JWT users still go through standard SETTINGS_UPDATE.
    - General PATCH /settings remains denied for API keys - flipping the
      narrow flag does NOT widen general settings-write access. Pinned by
      test_patch_settings_still_denied_with_energy_flag.

  Frontend: fifth "Update electricity price" toggle on the create-API-key
  card + amber "Energy" badge on existing keys with the flag set. Three
  new i18n keys across all 8 locales (German translated, English fallbacks
  elsewhere).
2026-05-15 13:12:41 +02:00
maziggy f2e3de0a63 fix(camera): start layer timelapse for queue/VP-dispatched prints (#1353)
Reporter @Andlar94 ran the external-camera flow on an A1 dispatched via the
  print queue and got no MP4 output even though the log said "Stitching layer
  timelapse for printer 1" after each print. Support bundle confirmed the
  external camera was working (Obico was polling the snapshot URL fine for
  plate detection).

  Root cause: start_session() only ran in the two new-archive paths in
  on_print_start (fallback_archive at main.py:2510 and regular new-archive at
  2600). The expected-archive branch at main.py:1981-2052 — where every
  reprint and every queue/VP-dispatched print lands — updated the existing
  archive row to status=printing but never started a timelapse session.

  So _background_layer_timelapse ran at print complete, called tl_complete(),
  found nothing in _active_sessions, returned None silently, and the wrapper
  at main.py:3917 produced no log message for the no-session case. Every
  print through the queue silently lost its timelapse — likely the reason
  this hasn't been caught before (direct slice-and-send-to-printer prints
  take the new-archive path and work fine).

  Fix: mirror the same start_session() call in the expected-archive branch,
  guarded by the same external_camera_enabled + external_camera_url check the
  other two paths use.

  Also reworded the snapshot URL help text across all 8 locales to make clear
  that timelapse and plate detection each require their own per-printer
  toggle — the URL is just the image source they pull from when active. The
  previous wording read as if filling in the URL was sufficient.
2026-05-15 12:46:59 +02:00
maziggy 7d3af9834c fix(inventory): emulate state=9 for bare-tray empty-slot signal on P1S/A1 (#1322)
Follow-up to the #1322 root fix. Reporter @RosdasHH traced the raw MQTT
  payload and found that P1S and A1 Mini send only {"id": N} for a
  physically empty slot — no state, no tray_type, no other fields. Without
  that signal, the assign-spool path was firing one wasted MQTT publish per
  click on a truly-empty slot (firmware dropped it silently, but still).

  The AMS parser in printer_manager.py now detects the bare-tray shape and
  promotes it to state=9 — the firmware's explicit "no spool" code — which
  lets the existing state in {9, 10} short-circuit in the inventory route
  apply automatically.

  The detection is intentionally narrow:

      len(tray) == 1 and "id" in tray and state is None

  so the post-Reset-Slot A1 Mini BMCU case (populated payload with state=3
  and tray_type="") has more than one key and stays unaffected — the #1322
  root fix is preserved.
2026-05-15 12:27:01 +02:00
maziggy bd552d5775 chore(tests): allowlist GitGuardian-flagged test fixtures in test_ldap_provision
Add `# pragma: allowlist secret` markers to the three lines GitGuardian
  flagged (ldap_server_url, ldap_bind_dn, admin_password) and pull the
  duplicated AdminPass1! literal into a single test_password variable so
  the marker only needs to live in one place.

  All values are test fixtures (test directory + admin password used only
  by the LDAP provisioning integration suite), not real credentials.
2026-05-15 12:15:39 +02:00
maziggy d6364646f8 feat(auth): manual LDAP user provisioning from the UI (#1298)
Reporter @Fuechslein flagged that disabling LDAP auto-provision left admins
  with no UI path to onboard new users — the create-user form had zero LDAP
  awareness and the only workaround was hand-editing the database.

  Add a Local / LDAP tab toggle to the create-user modal (hidden when LDAP is
  disabled). The LDAP tab is a debounced directory search (≥2 chars, 300ms)
  that returns up to 25 matches via the service-account bind, annotated with
  already_provisioned so existing usernames render disabled. Clicking
  "Provision user" re-resolves via the service bind and creates the user
  through the same _provision_ldap_user helper the auto-provision login path
  uses, so group mapping, default-group fallback, and email sync are identical
  regardless of which path created the user.

  The picker component is shared across all four create-user modal paths
  (UsersPage basic + advanced, SettingsPage basic + advanced).

  Two ldap3 schema-check workarounds were needed for OpenLDAP installs:
  - Open the search connection with check_names=False so ldap3 doesn't reject
    the cross-schema OR filter (sAMAccountName/displayName are AD-only)
  - Request attributes=["*"] because ldap3's build_attribute_selection
    validates each named attribute against the server schema regardless of
    check_names, and only the * wildcard is in its hard-coded exclusion list

  Login/lookup paths keep check_names=True so typos in user_filter still fail
  loudly.

  Backend
  - New routes: GET /auth/ldap/search, POST /auth/ldap/provision (both gated
    by USERS_CREATE; 503 details include ldap3 exception class + message)
  - Extract _open_service_connection + _extract_user_info helpers so
    authenticate_ldap_user, lookup_ldap_user, and search_ldap_users share the
    bind and attribute-extraction logic

  Frontend
  - New LdapUserPicker component (debounced search, result list, provision
    mutation, already-provisioned guard, error surface)
  - Tab toggle wired into UsersPage and SettingsPage modals, plus
    CreateUserAdvancedAuthModal props
  - 14 i18n keys added to en.ts (other locales fall back to English)
2026-05-15 12:05:43 +02:00
maziggy dca05ce6b1 fix(inventory): break the Reset-Slot deadlock on A1 Mini BMCU / P1S Standard AMS (#1322)
The original #1322 fix widened empty-slot detection to (state == 11 OR
  tray_type != ""), which closed the configured-slot reconfig case but
  didn't help the "Reset Slot on printer screen with spool still inserted"
  flow. On these firmwares the AMS reports state=3, tray_type="" after a
  Reset Slot regardless of whether a spool is physically present, so the
  empty-detection still decided "empty", skipped MQTT, marked pending —
  and on_ams_change replay never re-fired because the AMS never reported
  any state change either.

  RosdasHH traced the path: tray_state=3 falls into the else: branch,
  slot_is_empty = not (fingerprint_type and fingerprint_type.strip()),
  fingerprint_type is "", so slot_is_empty=True, MQTT is skipped, and the
  slot stays unconfigured forever. He verified empirically that removing
  the gate makes the firmware accept the push when a spool is physically
  present.

  Drop the tray_type fallback entirely. Only state in {9, 10} (firmware's
  explicit "no spool" / "spool present but no feed") short-circuits the
  MQTT publish. Every other state — including 3 (default-idle, ambiguous)
  and missing-state (older firmwares) — attempts the publish. Bambu's
  "firmware silently drops on empty slots" behavior makes the worst case
  a no-op for a truly-empty slot, and on_ams_change replay still serves
  as the safety net for state=9/10 slots whose spools get inserted later.

  pending_config is now (slot_is_definitely_empty OR not configured) so a
  printer-offline / no-client publish failure correctly flags the
  assignment for replay instead of falsely showing "configured".
2026-05-15 10:33:38 +02:00
maziggy 405dd1525b fix(firmware): keep download-URL resolution working when bambulab.com 403s (#1350)
The firmware update dialog showed "01.11.02.00 newer · Unavailable" with the
  misleading error "Firmware file is not available from Bambu Lab" while the
  logs spammed "Failed to get Bambu Lab page: 403". The wiki scrape was fine —
  only the Next.js buildId fetch on bambulab.com was being blocked by Cloudflare
  on the reporter's network, and the buildId was cached in memory only, so a
  single 403 broke download-URL resolution for the rest of the session.

  - Send Accept + Accept-Language headers alongside the honest Bambuddy/1.0 UA
    so the request stops tripping Cloudflare's "bare scraper" signal.
  - Persist the buildId to <data_dir>/firmware/build_id.json so a transient
    403 or a backend restart can't wipe a previously-valid buildId.
  - Add a download_page_unreachable flag and use it in the prepare-update flow
    to render an honest error ("page unreachable from this network — try later
    or download manually from bambulab.com") instead of implying Bambu doesn't
    have the file.
  - Retry the per-model JSON once when a cached buildId returns 404 (page
    rebuild), give up gracefully on 403 without churning.
2026-05-15 10:06:55 +02:00
maziggy f7c94cbc7b fix(inventory): add CF/GF to subtype dropdown on the Add/Edit Spool form (#1345)
Adding a third-party PETG-CF spool via the Material=PETG + Subtype=CF
  flow (same shape as the existing PETG HF) hit a missing option: KNOWN_VARIANTS
  in spool-form/constants.ts didn't list CF or GF. Users had to type it
  freehand into the "create new" tail of the dropdown.

  Added both: CF (matches PETG-CF / PLA-CF / ASA-CF / PA-CF) and GF (the
  natural pair for ABS-GF / PA6-GF). parsePresetName is unaffected — its
  materials list is iterated longest-first, so cloud presets like
  "Bambu PETG-CF Black" still resolve to material=PETG-CF with empty
  afterMaterial.
2026-05-15 09:47:21 +02:00
maziggy 7d7267e9cb fix(ui): z-index / stacking-context follow-ups to #1336
Two latent issues surfaced after the original AssignSpoolModal z-50 →
  z-[100] bump landed:

  1. Material-mismatch ConfirmModal hidden behind AssignSpoolModal.
     ConfirmModal's overlay was hardcoded to z-50 in its wrapper, so once
     the parent moved to z-[100] the nested confirmation dialog sat
     behind it. Added an optional overlayZIndex prop to ConfirmModal
     (defaults to z-50 — none of the 82 other call sites change), and
     the mismatch site in AssignSpoolModal passes z-[110] so the warning
     stacks above its parent.

  2. FilamentHoverCard / EmptySlotHoverCard covered by sibling printer
     cards on the dashboard. The popovers used position:absolute with
     z-[60] inside the trigger, but every printer card creates its own
     stacking context (drop-shadow filter on the slot tiles is enough),
     and z-index doesn't cross stacking-context boundaries — the next
     sibling card always wins by DOM order. Visible as the "Jade White
     · Bambu PETG HF" tooltip getting half-eaten by the neighbour card's
     AMS column.

     Fixed by portaling both hover cards to document.body with
     position:fixed and screen-space coordinates from
     triggerRef.getBoundingClientRect(). Coords recompute on visibility
     change, scroll (capture), and resize so the popover follows the
     trigger when the viewport moves; a requestAnimationFrame re-measure
     after the first paint avoids a one-frame flicker before the card
     has its rendered dimensions. Hover handlers are wired on both the
     trigger AND the portaled card so moving the cursor from slot to
     popover doesn't auto-dismiss after 100 ms. Top/bottom placement
     and arrow-pointer logic preserved.
2026-05-15 09:41:26 +02:00
maziggy 9ba1e34729 fix(archives): keep Quick Stats contribution when deleting a print (#1343)
Reported by @IndividualGhost1905: printing the same model ten times and
  then deleting nine archive entries (to keep the file list tidy) silently
  rewound the totals on the Statistics page — total prints, filament,
  cost, and per-print energy all dropped back to whatever the surviving
  row contributed, as if the other nine prints had never happened.

  Root cause: every metric in get_archive_stats is recomputed live from
  PrintArchive rows via COUNT / SUM, so removing a row removes its
  contribution. Energy in the default "Total" mode already survived
  deletion because it reads the smart-plug lifetime counters — that's
  the architectural shape we now generalise to the rest.

  Fix: soft delete with opt-in hard purge.

  Backend:
  - New nullable, indexed deleted_at column on print_archives, dialect-
    conditional migration (DATETIME on SQLite, TIMESTAMP on PostgreSQL).
  - ArchiveService.soft_delete_archive flips deleted_at and removes the
    files from disk (still reclaims storage); the path-safety checks were
    extracted into _resolve_archive_dir_for_delete so soft and hard delete
    share the rules.
  - DELETE /archives/{id} accepts ?purge_stats=true; default is soft.
  - Listings filter deleted_at IS NULL: list_archives, search FTS + LIKE
    fallback, GET /{id} (404 on soft-deleted), tag listing, duplicate
    detection (so a 1-live + 9-soft-deleted group no longer marks the
    survivor as a duplicate), and ArchiveComparisonService's "similar"
    suggestions. GET /stats and GET /slim deliberately do NOT filter so
    Quick Stats and the dashboard widgets keep counting deleted prints.

  Frontend:
  - ConfirmModal gained an optional children slot.
  - ArchivesPage (both card and detail views) own a per-instance
    deletePurgeStats boolean and render an opt-in checkbox in the delete
    dialog; resets to off on every close so the destructive option is
    never sticky.
  - api.deleteArchive(id, purgeStats?) appends ?purge_stats=true only
    when the box is ticked.
  - One new i18n key archives.modal.deletePurgeStats added across all 8
    locales (full German, English fallbacks elsewhere).
2026-05-15 09:21:43 +02:00
maziggy 7d07b92bec Post work PR #1333 2026-05-15 08:57:03 +02:00
Sn0rrii 8a7598f6b5 feat(auth): proxy OIDC provider icons server-side (#1333) (#1342)
* feat(auth): proxy OIDC provider icons server-side (#1333)

Strict img-src CSP blocked external OIDC icon hosts on the login page.
Loosening CSP was rejected via the MakerWorld precedent, so icons are
proxied: admin sets icon_url, backend fetches and caches the bytes in a
deferred BLOB column, the SPA renders from a same-origin
/api/v1/auth/oidc/providers/{id}/icon endpoint.
2026-05-15 08:50:37 +02:00
maziggy dd3e3f8039 fix(inventory): make AMS slot config land cleanly for spools with no k-profile
The assign flow was sending slicer-invalid values for tray_info_idx and an
  empty setting_id, which the slicer rejected — slot detail modal showed
  empty fields. With a stored k-profile the realignment path masked the
  issue; without one, garbage hit MQTT.

  Backend (apply_spool_to_slot_via_mqtt):
  - Discard tray_info_idx values that aren't real preset IDs: literal
    material names ("PLA", "PETG-CF") AND PFUS-prefix cloud setting_ids
    (valid as setting_id but rejected as tray_info_idx). Same check applied
    to current_tray_info_idx so stale slot values don't get reused as
    garbage.
  - Local-preset path now reads the printer-recognized filament_id from
    the preset's setting JSON (e.g. P4d64437) instead of falling through
    to a generic material ID.
  - Derive setting_id from filament_id_to_setting_id when empty so
    ams_filament_setting always carries a matched pair.
  - No stored k-profile: always send cali_idx=-1 (Default K), regardless
    of the live cali_idx on the slot. The live value belongs to whatever
    filament was there before, so reusing it would apply the wrong K to
    the new spool.

  Frontend (spool-form/utils.ts):
  - Local preset options use String(preset.id) as the unique code instead
    of preset.filament_type — every PLA local preset was collapsing onto
    the same "PLA" code, so picking any of them saved slicer_filament=
    "PLA" and lost the specific preset identity.

  Spoolman counterpart in spoolman_inventory.py mirrors the cali_idx=-1
  reset.
2026-05-14 19:20:58 +02:00
maziggy 5150a5a0fa fix(inventory): raise AssignSpoolModal z-index so it sits above the mobile sidebar drawer (#1336)
Both used z-50, so DOM stacking-order let the sidebar bleed over the
  modal on narrow viewports. Bumped to z-[100] to match the convention
  used by GitHubBackupSettings, FilamentHoverCard, and the Layout
  confirmation modal.
2026-05-14 17:07:48 +02:00
maziggy 500e99a48b Post work PR #1338 2026-05-14 15:35:13 +02:00
Chanakyan 0e3de29fbe fix(settings): show backup tab indicator dot for scheduled backups (#1338)
fix(settings): show backup tab indicator dot for scheduled backups
 fix(settings): invalidate settings cache when toggling scheduled backups
2026-05-14 15:32:57 +02:00
maziggy ccf985abfc feat(slicing): build-plate override in the SliceModal (#1337)
Slicing an STL via the integrated slicer always defaulted to whatever
  curr_bed_type lived in the chosen process preset (typically "Cool
  Plate"), which the slicer CLI rejected for high-temp filaments with
  "Plate 1: Cool Plate does not support filament 1". The user had no
  way to switch plates without cloning the preset in BambuStudio.

  The Slice modal now exposes a Build plate dropdown with the six
  canonical BambuStudio / OrcaSlicer plates (Cool Plate, Cool Plate
  SuperTack, Engineering Plate, High Temp Plate, Textured PEI Plate,
  Smooth PEI Plate) plus an "Auto (use process preset)" option that
  preserves the previous behavior. Positioned between Process profile
  and Filament rows so a long filament list never pushes it off the
  modal's scrolled viewport, and always enabled regardless of whether
  the user picked a Printer Preset Bundle.

  A new bed_type field on SliceRequest flows through both dispatch
  paths:
  - Resolved-preset path: _patch_process_bed_type overwrites
    curr_bed_type on the process JSON before forwarding to the sidecar.
    Works end-to-end today, no sidecar change needed.
  - Bundle dispatch path: slice_with_bundle adds a bedType form field
    to the sidecar multipart. The sidecar (maziggy/orca-slicer-api
    fork) needs a matching change to honor it as --curr_bed_type on
    the CLI invocation; until then the field is silently ignored and
    the slice runs with the bundle's default plate.
2026-05-14 15:28:41 +02:00
maziggy b51ef33472 fix(inventory): apply catalog color's gradient + effect, not just hex (#1340)
Picking a color preset from the catalog only copied color_name and
  rgba onto the spool — extra_colors (gradient stops) and effect_type
  (sparkle / wood / etc.) were silently dropped at three layers above
  the API: the SpoolFormModal state shape, the CatalogDisplayColor
  mapping in ColorSection, and the selectColor handler itself. All
  three widened to carry both fields through.

  Picking a catalog swatch now writes both fields from the entry, so
  solid presets cleanly replace previous gradients. Recent-colors and
  the hardcoded-fallback palette stay as plain hex pickers — they
  don't touch extras/effect since they aren't full presets.

  Also fixed the en-US `colour` → `color` drift in 8 locale files
  that the reporter flagged.
2026-05-14 14:52:18 +02:00
maziggy f45aaea97c fix(inventory): assign to AMS slot on firmwares that never report state=11 (#1322)
A1 Mini BMCU (01.07.02.00) and P1S Standard AMS (00.00.06.75) always
  report tray.state=3, even for loaded configured slots. The empty-slot
  detection preferred state==11 with tray_type as a fallback only when
  state was absent, so every assign was classified as empty and MQTT
  was skipped — both for "assign to unconfigured slot" and the secondary
  "PETG over a PLA-configured slot won't reconfigure" symptom.

  Empty-slot detection in the assign route and the on_ams_change replay
  now treats the slot as loaded when EITHER state==11 OR tray_type is
  non-empty. Reset-slot case (state=11 + tray_type="") still works
  through the first clause; configured slots on these firmwares now
  work through the second.

  Truly empty unconfigured slots (state!=11 + tray_type="") still hit
  the pending-config path, and the deferred publish now fires when the
  user later configures the slot in Bambu Studio (tray_type goes
  non-empty), since the replay uses the same disjunction.
2026-05-14 14:36:57 +02:00
maziggy 5c17cd4973 fix(inventory): restore Spoolman spool ID search + Unassign button (#1336)
Two regressions reported in #1336:

  1. spoolMatchesQuery did not include spool.id in the predicate, so
     typing a numeric Spoolman ID into the Assign Spool dialog or the
     Inventory page search returned no matches. Predicate now also
     tests String(spool.id).includes(q).

  2. The Unassign button in the spool edit modal was permanently
     disabled for Spoolman-mode spools. The modal only ever queried
     the legacy spool_assignments table (keyed by spool_id), but in
     Spoolman mode the assignment lives in spoolman_slot_assignments
     (keyed by spoolman_spool_id). Both the lookup query and the
     unassign mutation now branch on the spoolmanMode prop and call
     the Spoolman-flavored endpoints.
2026-05-14 14:19:12 +02:00