100 Commits
Author SHA1 Message Date
maziggy f2e8d5ae91 Hosekeeping 2026-06-28 13:57:10 +02:00
maziggy 7f937405be Merge remote-tracking branch 'origin/main' into 0.2.4.8 2026-06-28 13:56:09 +02:00
maziggy 8f4c8375cb test: silence Bandit B108 on hardcoded /tmp test-fixture paths
Three test fixtures pass a string-shaped /tmp path into PrintArchive
rows. The file is never created — the field is just a DB column the
ORM accepts as a string — but Bandit's B108 rule fires on any literal
/tmp/ path it sees in source. Suppress with the same `# nosec B108`
marker convention test_archives_api.py and test_queue_start_user_attribution.py
already use for the same shape.
2026-06-28 13:52:05 +02:00
maziggy a46b120336 Housekeeping 2026-06-28 13:40:52 +02:00
maziggy 01d688f82d Housekeeping 2026-06-28 12:51:43 +02:00
maziggy b2b04fc4aa fix(inventory): assign-spool picker note visible on mobile (#793 follow-up)
The original #793 fix added the spool note as an HTML title= tooltip
    on each picker button in AssignSpoolModal.tsx. title= only surfaces
    on hover, which doesn't exist on touch devices — phone users tapping
    a card just selected it, the note never appeared. Users who store
    their tracking ID in the note field were blind on mobile (raised by
    @EmcetPL on the closed issue).

    Render the note as a small muted truncated line directly under the
    weight on both the internal-inventory branch (line 436-ish) and the
    Spoolman branch (line 510-ish): text-[10px] text-bambu-gray/70 mt-1
    truncate, kept inside the truthy `&&` guard so empty notes don't add
    a blank row. The existing title={spool.note} is preserved on the new
    <p> so desktop hover and mobile long-press still surface the full
    untruncated text for notes that overflow the truncate.

    Mirrored across both inventory branches per the parity rule
    (internal and Spoolman pickers stay shape-equal). No backend change,
    no new state, no popover, no new touch target.
2026-06-28 12:48:18 +02:00
maziggy 4c79563630 fix(auth): API keys with Manage Library can curate library files (#1832)
require_ownership_permission gates API keys on `all_perm` only — the
    comment at auth.py:1659 says OWN and ALL "both map to the same scope
    flag" for queue / archives / etc., so checking `all_perm` is the
    correct gate. Library deliberately broke that: LIBRARY_UPDATE_OWN /
    LIBRARY_DELETE_OWN mapped to can_manage_library, but the ALL variants
    were in _APIKEY_DENIED_PERMISSIONS. Result — every API-key request to
    DELETE /library/files/{id}, PUT /library/files/{id} (rename), or
    POST /library/files/move hit "administrative operations" 403, even
    for keys with can_manage_library=True. Only slice worked, because it
    doesn't go through require_ownership_permission.

    The "ALL stays admin-only because it crosses the user boundary"
    intent was internally inconsistent. API keys have no per-row
    ownership identity (user=None), so the route's
    `file.created_by_id != user.id` ownership check would AttributeError
    on a key acting under OWN anyway — the only working path is
    can_modify_all=True, which `all_perm` denial blocked outright.

    Fix folds LIBRARY_UPDATE_ALL and LIBRARY_DELETE_ALL into
    _APIKEY_SCOPE_BY_PERMISSION under can_manage_library, matching the
    can_queue precedent (QUEUE_UPDATE_OWN and QUEUE_UPDATE_ALL both
    map to can_queue for the same per-key-identity reason). Both removed
    from _APIKEY_DENIED_PERMISSIONS. LIBRARY_PURGE stays denied — it
    bypasses the soft-delete window and is genuinely destructive.
2026-06-28 12:47:59 +02:00
maziggy 257b9e2c89 feat(sponsor-prompt): lower print/archive/cost thresholds to fire for typical new installs
The toast was calibrated for power users — lowest bars were 100 prints,
    50 archives, 100 cost. Most installs never crossed any of them, especially
    with the install base ~doubling since March. Matomo confirms: only 4
    prints-100 and 3 archives-50 deeplink visits to /sponsors.html in a 7-day
    window despite tens of thousands of weekly pulls.

    Adds lower thresholds without changing priority order or cooldown:
      PRINT_MILESTONES   = (10, 25, ...)
      ARCHIVE_MILESTONES = (5, 10, ...)
      COST_MILESTONES    = (25, 50, ...)

    Existing toast copy uses {count}/{total} interpolation in all 11 locales,
    so no i18n changes. Tests rebalanced so "below the floor" still tests
    with the new floor; new test_fires_at_lowest_threshold pins prints-10.
2026-06-28 12:47:42 +02:00
maziggy 00e4aed7af fix(printers): equalize external tray height with regular AMS slots
On dual-nozzle printers (H2C/H2D), the External card stacked a
    separate "Ext-L" / "Ext-R" caption below each tray to mark which
    extruder it fed. That caption appeared on the External card only,
    making the bottom row of the printer card's AMS panel visibly
    taller than the row above it.

    Fix: the L/R distinction now lives inside the slot's colour circle
    in place of the numeric index, and the bottom caption is removed.
    FilamentSlotCircle's slotNumber prop is widened to `number | string`
    to carry the letter. Single-nozzle externals (one tray, no L/R
    distinction) keep the numeric "1".

    The Ext-L / Ext-R strings still drive the slot's "location" label
    in the filament hover card, so detail context is preserved.
2026-06-28 12:47:22 +02:00
maziggy 458bfa157b chore(deps): floor-pin pydantic-settings >=2.14.2 + msgpack >=1.2.1 for clean pip-audit
pip-audit flagged two advisories at the resolved versions in the venv.
      Neither is reachable in shipped Bambuddy, but the pins are taken so
      the audit stays clean and a future reachable advisory in either
      package isn't masked by existing noise.

      pydantic-settings 2.14.2 patches GHSA-4xgf-cpjx-pc3j —
      NestedSecretsSettingsSource with secrets_nested_subdir=True followed
      symlinks pointing outside the configured secrets_dir, reading
      out-of-tree files into settings values and bypassing the documented
      secrets_dir_max_size cap. Affected: >=2.12.0, <2.14.2. Bambuddy uses
      pydantic-settings only for env-var-backed config; the secrets-dir
      loader is not used (grep clean on NestedSecretsSettingsSource /
      secrets_nested_subdir / secrets_dir under backend/).

      msgpack 1.2.1 patches GHSA-6v7p-g79w-8964 — reusing an Unpacker
      instance after it caught an error can crash with SEGV, which is a
      DoS vector on untrusted input. msgpack is not a runtime dep of
      Bambuddy; it enters the tree only as a transitive of CacheControl,
      itself pulled by pip-audit (the very tool that surfaced the
      advisory). Pin placed in requirements-dev.txt next to pip-audit so
      it travels with the security-scan tooling rather than implying a
      runtime use.
2026-06-28 12:47:04 +02:00
maziggy 549d3216d4 feat(auth): SSO autologin + disable local username/password login (#1589)
Adds a global local_login_enabled setting plus a per-provider
      is_autologin flag on OIDCProvider so operators who run their own SSO
      enabled, or if the calling admin has no UserOIDCLink — either would
      lock everyone out. App-layer invariant: at most one provider can carry
      is_autologin; setting it on one clears it on every other.

      /auth/advanced-auth/status surfaces both new fields so the LoginPage
      decides UI in one query. The env-var bypass flips the reported
      local_login_enabled back to true so the SPA matches what the route
      will accept.
2026-06-28 12:46:43 +02:00
maziggy 1f34364cad Post work PR #1814
fix(db): order filament_shopping_list color_name ALTER after CREATE

      PR #1814 added ALTER TABLE filament_shopping_list ADD COLUMN color_name
      before the CREATE TABLE IF NOT EXISTS for that table. On fresh installs
      the ALTER hit "no such table" — not in _safe_execute's swallow list —
      and aborted run_migrations, breaking every migration test that starts
      from a fresh DB. Moved the ALTER to after the CREATE on both SQLite and
      Postgres branches; the CREATE already declares color_name, so this is
      purely the upgrade path and "duplicate column name" on re-runs is
      swallowed.
2026-06-28 12:46:23 +02:00
maziggy 3cbdba0cac [Fix] Forecasting: Group spools by color and rework UI (#1814) 2026-06-28 12:46:04 +02:00
maziggy 5e008744de fix(notifications): false-positive Print Stopped on reprint after MQTT reconnect (#1807)
Reprints triggered a bogus "Print Stopped" push notification while the print
      kept running, surfaced by the reconciler synthesising a missed PRINT COMPLETE
      on MQTT reconnect.

      bambu_mqtt:3647 mints a fresh subtask_id per dispatch. On reprint, the
      on_print_start expected-archive promotion only wrote subtask_id when the
      stored value was empty (`not archive.subtask_id`) — so the archive kept the
      FIRST run's id. On the next MQTT reconnect, reconcile_stale_active_prints
      (#1542) compared the stale stored id against the printer's live id, found
      a mismatch, and synthesised a status="aborted" PRINT COMPLETE — which fires
      the "Print Stopped" notification.

      Captured cleanly in the reporter's support bundle:

        [RECONCILE] Printer 1: synthesising missed PRINT COMPLETE for archive 31
          — subtask_id changed ('1844213296' → '2103771517')

      immediately followed by gcode_state: RUNNING on the same wire.

      Fix: update archive.subtask_id whenever the new effective id differs from
      the stored one, not only when the stored one is empty. Inequality check
      preserves the noop-on-stable-push behaviour the original guard provided.

      Two places in main.py (expected-print and duplicate-printing-archive
      branches). 3 new unit tests cover the reprint, first-run, and stable-push
      paths. Reconciler itself unchanged — it was doing the right thing given
      the data it had.
2026-06-28 12:45:48 +02:00
maziggy 8b72b305a3 fix(inventory): stop popping the unknown-tag modal for slots with no RFID
The 7cb905a follow-up mounted the global unknown-tag modal listener, which
      turned an existing always-on broadcast for no-tag slots from a silent no-op
      into a perpetual popup loop — every push for a slot with a generic
      non-RFID spool (or zero-filled tag) re-prompted, and confirming each one
      created a fresh ghost spool with an empty tag.

      - main.py on_ams_change: drop the no-tag else-branch broadcast. No identity,
        no prompt; the slot stays unassigned until a real tag is read.
      - inventory.py + spoolman.py /spools/from-slot: 400 when the slot has no
        usable tag_uid / tray_uuid so stale frontends can't recreate the ghost
        spool by re-confirming a queued prompt.
      - test_inventory_from_slot_no_tag: lock the guard in (zero-filled + empty
        string).
2026-06-28 12:45:35 +02:00
maziggy cd5a02c06f fix(spoolbuddy): close #1815 — preserve PFUS/PFCN setting_id in resolver
SpoolBuddy "Assign to AMS" with a Bambu Cloud user preset (PFUS) left
      Bambu Studio showing "Generic <Material>" instead of the user's
      custom preset. Root cause: the defensive filter that catches
      PFUS/PFCN leaks into tray_info_idx also cleared setting_id —
      but PFUS/PFCN are VALID setting_id values, just not valid
      tray_info_idx values. When the cloud detail lookup didn't return
      a filament_id (cloud unauth on the on_ams_change replay path,
      transient failure, or older custom presets), both fields got
      cleared and the caller's generic-material fallback overwrote
      setting_id with GFSG99 — slicer resolved to Generic PETG.

      Fix: the filter still clears tray_info_idx for PFUS/PFCN/material-
      name leaks, but preserves setting_id when it's a valid slicer
      reference (PFUS / PFCN / GFS). Material-name leaks still clear
      both. Post-fix MQTT carries tray_info_idx=GFG99 (firmware-acceptable
      for HMS/drying/colour) AND setting_id=PFUS<hash> (slicer uses this
      to load the actual user preset).

      What stays the same: Bambuddy's own AMS card still displays
      the generic material on cloud-unauth paths — same fundamental
      limitation as today. Fixing that needs a deeper layered fallback
      (LocalPreset name match, printer kprofile query, cloud-detail
      cache) and is out of scope for this drop. Slicer-side fix is
      the reporter's explicit ask.
2026-06-28 12:45:21 +02:00
maziggy 2bd2bce301 fix(mqtt): close #1822 — promote H2S tray_now to 254 on all-external prints
H2S firmware reports tray_now=0 (the AMS's idle slot) throughout
      external-spool prints instead of 254 like X1C/P1S/A1 do, so the
      single-nozzle branch's 0-3 passthrough landed state.tray_now on slot
      0 — UI highlighted AMS SLOT 1 instead of the external spool.
      Usage credit was unaffected (#1276 covers that via ams_mapping).

      The single-nozzle branch now checks _captured_ams_mapping (slicer-
      captured per-filament mapping that the request-topic intercept
      already tracks) before the existing P2S multi-AMS resolver. When
      every entry is -1, the print uses ONLY the external spool, so
      state.tray_now is promoted to 254.

      Narrow on purpose: AMS-only [5] and mixed [5, -1] are NOT
      overridden — we have no evidence H2S misreports mid-print swaps, and
      trusting the firmware preserves correctness for users with multi-
      filament setups. No-mapping prints (printer-screen start) fall
      through unchanged.
2026-06-28 12:45:06 +02:00
maziggy ba7af59bdd fix(queue): close #1818 — Resume after failure clears the gate
Single failure on a printer with require_previous_success queue items
      permanently skipped every downstream + every new item — the
      _check_previous_success lookback always walked back to the original
      failed row (skipped is excluded from the lookback), and no code path
      could dismiss that failure.

      Three pieces:

      1. PrintQueueItem.gate_acknowledged Boolean column (default False).
         SQLite/Postgres-safe ALTER, dialect-branched DEFAULT.

      2. _check_previous_success skips rows where gate_acknowledged=True so
         acknowledged failures walk past the lookback. Fresh post-resume
         failures still gate independently.

      3. POST /api/v1/queue/printer/{printer_id}/resume — gated on
         QUEUE_UPDATE_ALL — acknowledges failed/aborted items for that
         printer AND restores items where
         status='skipped' AND error_message='Previous print failed or was
         aborted' back to pending in one transaction. Returns
         {acknowledged, restored}.

      Frontend banner above the active Queue tab surfaces blocked printers,
      fires a warning-variant ConfirmModal, and shows a precise toast on
      success.
2026-06-28 12:44:49 +02:00
maziggy c52aba66e4 fix(archives): correct #1812 — Step 4 wiki link host
Banner pointed to bambuddy.cool/wiki/getting-started/... which 404s;
      the wiki lives under the wiki.bambuddy.cool subdomain. Anchor was
      correct (MkDocs slug matches the existing "Step 4: Enable Store sent
      files on external storage" heading).
2026-06-28 12:44:34 +02:00
maziggy 71b0575ff9 fix(vp): close #1780 race — bump slicer-MQTT wait to 5s + retroactive stamp
Round 2 (166e9f9e) fixed the stash-key mismatch, but @mkoreen's
      2026-06-23 bundle showed BS's MQTT project_file arrived 85 ms past the
      2.0 s wait timeout (FTP done 00:42:02.509, "No slicer options cached"
      00:42:04.509, MQTT 00:42:04.594). Queue item was committed with
      settings defaults; nozzle_mapping never made it onto the wire.

      Three pieces:

      1. _SLICER_OPTIONS_WAIT_TIMEOUT module constant, 2.0 -> 5.0 s. Covers
         wireless / loaded-Pi jitter; one-time +3 s cost only for legacy
         slicers that never send MQTT.

      2. _RECENT_QUEUE_ITEM_TTL fallback: on_print_command retroactively
         UPDATEs slicer-driven fields on a recently-committed queue item
         when the event wait already gave up. Tracked via
         _recent_queue_items dict (30 s TTL, evicted on every queue-add).
         Gated on status='pending' so we never race the dispatcher.
         Multi-plate covered via WHERE id IN (...).

      3. Post-commit last-chance pop. Audit caught a race in (2): MQTT could
         arrive during any await inside _add_to_print_queue (wait_for,
         archive_print, db.flush, db.commit), and on_print_command would
         stash data with no event consumer AND no _recent_queue_items entry
         yet. After populating _recent_queue_items, _add_to_print_queue now
         pops _slicer_print_options[file_path.name] one last time and
         routes any hit through _restamp inline.
2026-06-28 12:44:09 +02:00
maziggy 9f9c17752f feat(inventory): toggle to disable auto-add of unknown RFID spools + global confirmation modal (issue #1764)
New setting "Auto-add unknown RFID spools" under Settings -> Filament -> Filament Tracking,
      default ON for back-compat. When turned off, the backend stops auto-creating an inventory
      record for an unknown RFID tag and instead broadcasts an unknown_tag WS event that pops
      a global confirmation modal in the Bambuddy UI showing the printer / AMS-X label / slot /
      material / colour. Add or Cancel; no nag on every MQTT push.

      Backend
      - Module-level _unknown_tag_last_broadcast dict dedupes per (printer, slot, tag). Set is
        committed AFTER ws_manager.broadcast() returns so a crashed broadcast doesn't poison
        the dedup and permanently silence the slot.
      - Empty-slot MQTT push clears that slot's entry, so remove+reinsert reliably re-prompts.
      - Successful matches via get_spool_by_tag / find_matching_untagged_spool / create_spool
        also clear the entry so a future tag swap re-prompts.
      - Tray data (tray_type, tray_color, tray_sub_brands, tray_count) shipped in the WS payload
        directly so the modal renders the real material / colour instead of relying on the
        React Query cache that lags the WS event by several seconds.
      - Two new endpoints back the modal's confirm action:
          POST /api/v1/inventory/spools/from-slot     (INVENTORY_UPDATE)
          POST /api/v1/spoolman/spools/from-slot      (FILAMENTS_UPDATE)
        Both look up the slot's tray data server-side and create + auto-assign atomically.
      - Spoolman /from-slot now raises HTTP 500 when the slot-assignment INSERT fails instead
        of returning success while the DB rolled back the binding.
      - sync_ams_tray gained an optional auto_add_unknown_rfid kwarg (default True so existing
        callers are unaffected); auto-sync and both manual sync routes thread the setting.

      Frontend
      - useUnknownTagPrompt hook listens for the unknown-tag CustomEvent, reads the tray fields
        out of the event detail, and feeds a single-modal queue. No long-lived dismissed set;
        the backend dedup handles spam suppression.
      - UnknownSpoolModal wraps the existing ConfirmModal with a material + colour-swatch
        preview block.
      - Mounted in Layout.tsx alongside useSponsorPrompt so SpoolBuddy kiosk / login / setup
        routes are excluded.
      - getAmsLabel moved to utils/amsHelpers.ts; ConfigureAmsSlotModal.tsx and PrintersPage.tsx
        both import the shared version (canonical AMS-A / HT-A / External labels).
      - AppSettings TS interface gained spoolman_enabled, auto_add_unknown_rfid, spoolman_url
        so the runtime cast in the hook is no longer needed.
      - SpoolmanSettings.tsx gets a new toggle row in the Filament Tracking card, visible in
        both built-in and Spoolman branches; auto-save + toast already wired.
2026-06-28 12:43:43 +02:00
maziggy 2932ad96f5 Post work PR #1798 2026-06-28 12:43:26 +02:00
maziggy 8e99b0c86d Fix camera port diagnostic for A1/P1 printers (#1799) 2026-06-28 12:43:02 +02:00
maziggy 29a5abd986 feat(deficit): backup-aware filament deficit check, colour-strict (#1762)
When the printer reports ams_filament_backup=True,
      compute_deficit_for_queue_item pools remaining_grams across spools
      matching (preset, colour) on the same printer (scoped per extruder on
      dual-nozzle) before declaring a per-slot shortfall. Identity is strict:
      same slicer_filament preset AND same colour (alpha-normalised). Two
      PETG HF spools in different colours are NOT pooled — the firmware would
      swap correctly but the print would change colour mid-run. Spoolman side
      mirrors the rule via filament.id + color_hex. Backup OFF falls back to
      the pre-PR per-slot accounting line-for-line.

      8 new test cases in TestFilamentDeficitBackupAware pin pool covers,
      pool insufficient, different presets, backup-OFF regression, dual-
      extruder side scoping, no-preset never pairs, colour-strict, and
      alpha-hex normalisation. The 8 pre-existing test_filament_deficit.py
      cases stay green.

      feat(printers): AMS Filament Backup modal with BS-style ring per pair

      Badge click on the Filaments section header (#1766) now opens a
      modal: filament-colour ring per backup pair, material name + rotation
      count in the centre, slot labels distributed around the colour band on
      contrast-aware pills. Closely modelled on Bambu Studio's Auto Refill
      widget. Lone slots are intentionally not listed. R / L badges per ring
      when the extruder map carries two distinct values; collapses to no-
      badge rendering for single-nozzle printers misflagged as dual.

      Esc keypress closes the modal. Theme-aware via CSS variables matching
      AMSHistoryModal. computeBackupGroups helper in utils/amsHelpers
      defensively dedupes duplicate ams.id entries observed on switch-VP
      aggregations.

      10 modal render cases pin: Esc closes / unmount nulls the listener /
      ring renders for pairs and omits lone slots / R-L badges only when
      extruder map has distinct values / empty state / toggle gating.
      13 frontend cases pin computeBackupGroups identity rules.

      feat(printers): active-print P-N pill on AMS slot tiles during RUNNING

      While the printer is mid-print, each AMS slot tile referenced by
      status.ams_mapping carries a small "P1 / P2 / P3" pill in the top-
      right corner, naming which print-slot is mapped to that AMS slot.
      Catches the #1762 comment-2 scenario: a queue job set for "any X1C"
      staged to a printer with mismatched filament, no way to verify mid-
      print. Same wire data (status.ams_mapping is already on the wire) —
      the addition is purely surface.

      The existing ring-bambu-green highlight for effectiveTrayNow keeps its
      meaning (currently extruding RIGHT NOW); the pill is the per-slot
      static assignment for the active print.

      chore(scheduler): log Print Anyway short-circuit at INFO

      _block_on_filament_deficit logs at INFO when it honours
      item.skip_filament_check, so a future "Print Anyway didn't work" report
      (third commenter on #1762 hit this shape) has actionable evidence in
      the standard support bundle without DEBUG. Bundled because the deficit
      fix makes the original symptom disappear for users with backup ON.
2026-06-28 12:42:25 +02:00
maziggy a9bf6f1f79 fix(notifications): sync finish-photo producer→consumer to land the photo on FINISH-state fallback (#1790)
On the FINISH-state fallback path bambu_mqtt.py:3258 dispatches
      on_finish_photo_moment and on_print_complete back-to-back, so the
      moment-producer's RTSP grab and the print-complete consumer's cache
      read race — consumer wins the empty pop, then its own RTSP fallback
      times out against the producer's in-flight grab (Bambu printers allow
      one RTSP client). 394 KB frame captured, notification went text-only.

      Add a per-printer asyncio.Event in _stage22_finish_in_flight: producer
      registers before first await, sets it in finally on every exit;
      consumer awaits with a 20s timeout (15s producer grab + headroom)
      before the cache pop. Closes the race AND the concurrent-RTSP timeout
      in one change. Timelapse path skips the event, so its branch is
      unchanged.
2026-06-28 12:42:05 +02:00
maziggy 30c2e263dd fix(vp): correct #1780 root cause — VP intake key mismatch dropped every slicer field
First-attempt fix (d196cfc5) was wrong about the cause. Real root,
      traced via @mkoreen's BAMBUDDY_VP_DUMP_WIRE capture + 2026-06-21
      support bundle:

      mqtt_server.py:1296 was passing the slicer's bare subtask_name
      (e.g. "Model_Name") into on_print_command, which stashed under
      that key. _add_to_print_queue looked up under file_path.name
      (the FTP filename WITH extension, "Model_Name.gcode.3mf"). The
      two strings never matched. pop returned None, the 2s wait fired
      against a key the stash side never signaled, every captured
      slicer field silently fell back to settings defaults.

      Affected EVERY Bambu Studio "Send" upload across EVERY model —
      not just H2C nozzle_mapping. bed_leveling / flow_cali /
      vibration_cali / layer_inspect / timelapse from the original
      #1403 capture have been silently ignored since BambuStudio
      started splitting subtask_name (bare) from file (with extension).

      Unit tests passed because fixtures called on_print_command with
      file_path.name directly, bypassing the broken caller.

      Fix in manager.py::on_print_command: derive
      stash_key = data.get("file") or filename and use it for both
      _slicer_print_options and the event lookup. filename
      (subtask_name) still flows unchanged to _schedule_finish_release
      — push_status echoes it back as gcode_file / subtask_name and
      the slicer matches against its own subtask_name there, so
      re-routing that path was a separate regression I caught and
      reverted mid-audit.

      Also: nozzles_info field was a wrong guess in d196cfc5 —
      BambuStudio never sends it (confirmed via wire capture). Drop
      the capture, dispatch, schema, kwarg, and route paths. DB
      column stays nullable so old rows still load; nothing reads
      or writes it.

      Diagnostic: DEBUG log when _add_to_print_queue finds no slicer
      options after the 2s wait, including the looked-up key and the
      actual cache keys present. Future stash/lookup mismatches will
      be obvious from a log line instead of needing a wire capture.

      Behaviour change worth flagging: users on Bambu Studio whose
      slicer-side bed-leveling / flow-cali / vibration-cali /
      layer-inspect / timelapse differ from Bambuddy's
      default-workflow settings will see their slicer choices
      honored now instead of silently overridden. Restores #1403's
      original intent.
2026-06-28 12:41:31 +02:00
maziggy 7b06ebd760 feat(queue): drag-reorder grouped queue items; collapsed batches no longer block
Batches were stuck where they were created. The parent row had no drag
      handle and wasn't registered with dnd-kit's SortableContext, so the
      only way to move a grouped item was to ungroup, drag, and re-group.
      Collapsed batches also acted as unmovable obstacles for adjacent items.

      The batch parent now registers under a synthetic "batch-<id>" string
      id and carries a GripVertical handle in the header (gated by
      queue:reorder). handleDragEnd resolves both endpoints: dragging a
      batch moves all its children as one block, dropping onto a batch
      anchors at the batch's first child so the group lands immediately
      before it. Direction-aware insert uses the first moving id's index
      instead of the previously single dragged id, so multi-row drags and
      batch drags share the same insert math. Within-batch child reorder
      is unchanged. DragOverlay gained a batch ghost showing
      "<name> (<N> copies)".
2026-06-28 12:41:10 +02:00
maziggy 7e5eff14d8 feat(humidity): per-filament humidity threshold for auto-drying + alarms (#1605)
Reporter @thenewguy runs an engineering farm with one AMS per material
      (PLA, ASA, Nylon, PVB, HIPS) — Bambuddy's single global ams_humidity_fair
      threshold (default 60%) was driving both the queue / ambient auto-drying
      trigger AND the hourly humidity alarm uniformly, which is wrong for
      multi-material setups where Nylon wants <10% and PLA is fine at 60%.

      Drying RUN parameters were already per-filament via drying_presets;
      this commit adds the missing per-filament TRIGGER.

      New setting ams_humidity_thresholds — JSON map of filament-type to
      threshold percent with a "default" key for unknown / unmapped types.
      Empty / unset → both consumers fall back to ams_humidity_fair so the
      upgrade is silent.

      Resolver lives in PrintScheduler.resolve_humidity_threshold(trays,
      thresholds, fallback) — picks the lowest (most-restrictive) threshold
      across all loaded tray types, matching the conservative-params strategy
      _get_conservative_drying_params already uses for temp / hours. Empty
      tray slots contribute no constraint; all-empty AMS falls through to the
      "default" key. Filament names normalized to uppercase base (so
      "PLA Basic" / "pla basic" both map to PLA).

      Two consumer sites rewired through the same resolver so the scheduler
      and the alarm path can never disagree about whether an AMS is "too
      humid":
        - print_scheduler.py::_check_auto_drying — per-AMS humidity comparison
          for start / stop / skip decisions.
        - main.py AMS sensor / alarm worker — hourly humidity alarm notifier.

      UI: new table in Settings → Workflow → Auto-Drying, below the existing
      Drying Presets table. Default row + 8 default filament types
      (PLA / PETG / TPU / ABS / ASA / PA / PC / PVA) pre-filled from the
      current ams_humidity_fair value so the editor starts sensibly.

      Input pattern: draft-on-edit / commit-on-blur (transient humidityDrafts
      state per row). onChange only updates the draft; onBlur (and Enter)
      parses + clamps to [5, 95] + commits. Empty value on blur clears the
      override and falls back to default. Caught mid-PR via a typing test:
      the naive per-keystroke clamp snapped "3" → 5 before the user could
      type the second digit of "30".

      Setting is in the public _UI_PREFERENCE_FIELDS allowlist (same rationale
      as drying_presets and ams_humidity_fair — non-sensitive integer map,
      no SETTINGS_READ permission required for badge-color rendering).
2026-06-28 12:40:40 +02:00
maziggy ee27092299 feat(printers): per-printer Maintenance Mode toggle (#1476)
Operator-flipped out-of-service state per printer for three scenarios:
      parallel Bambuddy installs (dev + prod where the printer rejects all
      but one MQTT client), printers under repair, and temporary suspension.

      The backend Printer.is_active gate has shipped since day one and is
      already honoured by every consumer — MQTT (printer_manager), queue
      dispatch (print_scheduler, print_queue), metrics, scheduler, picker,
      backup, maintenance dashboard. The missing piece was UI exposure.

      Three entry points to flip is_active:
      - Three-dot overflow menu (Enter / Exit maintenance mode, wrench icon)
      - Exit button inside the in-card amber panel
      - Checkbox in EditPrinterModal

      Card UI: expanded mode shows an amber panel (Wrench + "In Maintenance"
      + subtitle + Exit) where the cover/progress container would normally
      render — same height, no layout shift. Compact mode shows an amber
      pill in place of the progress bar. Header pill swaps Connected/Offline
      for "Maintenance" and the diagnostic CTA is suppressed (deliberate
      state, not involuntary offline). HMS / Queue / Firmware pills fall
      away naturally via the existing status?.connected gates.

      Mid-print entry (RUNNING / PAUSE) triggers a confirmation dialog —
      disconnecting MQTT mid-print stops progress tracking and completion
      notifications for the in-flight job. Idle / FINISH / FAILED skip the
      dialog and toggle directly.

      Scope: no backend change, no new permission, no behaviour change for
      any other consumer. PrinterCreate.is_active?: boolean added to the
      TypeScript surface so the field flows through api.updatePrinter.
2026-06-28 12:39:55 +02:00
maziggy 568f220a5a fix(printers): hide chamber fan badge on open-frame Bambu models
The Printers page rendered three fan widgets (part / aux / chamber)
      for every printer unconditionally. Open-frame models (A1, A1 Mini,
      A2L, P1P) have no chamber fan — the firmware reports big_fan2_speed
      as 0, so the badge always rendered greyed-out and let users "set" a
      fan speed on hardware that doesn't exist.

      Adds MODELS_WITH_CHAMBER_FAN allowlist (X1C / X1 / X1E / X2D / P1S /
      P2S / H2D / H2D Pro / H2C / H2S) near mapModelCode, and the chamber
      entry is spread into fanItems only when the printer's model is in the
      set. Open-frame printers now show two badges (part + aux), which
      matches their actual hardware.

      Allowlist not denylist: mirrors the file's existing classification
      pattern (the enclosure-door badge gate uses the same shape), and the
      failure mode is preferable — a missing badge on a real chambered
      printer is obvious; a phantom badge on a future open-frame model
      would look correct and silently lie.
2026-06-28 12:39:32 +02:00
maziggy b7ff72d856 fix(updates): switch Windows installer installs to release-asset update flow
In-app "Install Update" on Windows installer installs failed with "Could
      not find git executable" because (1) _find_executable's fallback paths
      are Unix-only, and (2) the installer stages backend/ via shutil.copytree
      so there is no .git directory — even with Git for Windows installed, the
      fetch would die on "not a git repository". Adding Windows paths would
      only have changed which error users saw.

      Switches the Windows installer path to a fourth update_method
      ("windows_installer") that mirrors the existing docker / ha_addon
      branches — surface a link to the release .exe and let the user re-run
      the installer, matching the Discord / Spotify Windows update model.

      Backend:
      - New _is_windows_installer_install() — true iff sys.platform == "win32"
        AND no .git in app_dir, so Windows devs with a real git clone keep
        the git path.
      - New _find_windows_installer_asset() picks the matching release asset
        (prefers versioned bambuddy-<ver>-windows-x64-setup.exe, falls back
        to the unversioned alias on non-daily tags).
      - /updates/check now returns is_windows_installer / update_method /
        installer_download_url.
      - /updates/apply short-circuits with a friendly message after the
        existing HA / Docker guards — defense in depth, the frontend swaps
        the button so the POST should not fire on Windows.

      Frontend:
      - UpdateCheckResult extended with the new fields and 'windows_installer'
        in the update_method union.
      - SettingsPage renders a Bambu-green styled <a target="_blank"
        rel="noopener"> between the Docker snippet and the in-app Update
        button, with installer_download_url falling back to release_url then
        the tag page so the link is never broken.
      - applyUpdateMutation onSuccess toast guard extended to treat
        is_windows_installer the same as HA / Docker.
2026-06-28 12:39:10 +02:00
maziggy b73c21f442 Updated .github/workflows/cleanup-ghcr.yml 2026-06-28 12:38:51 +02:00
maziggy e761e09297 feat(sponsor-prompt): in-app toast at earned milestones
ghcr.io pull baseline (~10k/day rising → ~8-12k active installs) puts
      sponsor conversion at 0.08% — roughly an order of magnitude under
      industry-benchmark for OSS with visible CTA. The Settings banner from
      0d4b9d4e gives passive every-visit visibility on one page; this adds
      opt-out-able active visibility at moments where the user has just
      earned something with Bambuddy.

      Five trigger families with a 14-day cross-family cooldown: prints
      (100/500/1000/2500/5000), cost (100/500/1000 tracked filament +
      energy), archives (50/250/1000), anniversary (1 year), version-update
      (re-armable on each major bump). New sponsor_toast_state table with
      nullable user_id so auth-disabled installs get the same trigger logic
      through one code path (NULL-keyed install-default row).
2026-06-28 12:38:31 +02:00
maziggy 5c16ef3e58 feat(settings): prominent sponsor banner on General tab
Matomo shows only 1.18% of website visitors reach /sponsors despite
      29% hitting /installation. The ask was discoverable on the marketing
      site but invisible in-app where users actually live.

      Full-width gradient banner sits above the three-column layout on the
      default landing tab and links to bambuddy.cool/sponsors.html with a
      ?from=app-settings tracking param so conversion lift is measurable
      in Matomo. Three new sponsors.* keys translated to all 11 locales.
2026-06-28 12:38:11 +02:00
maziggy d1d166592c feat(heater-history): track nozzle / bed / chamber readings + per-tile chart-icon overlay opens history modal
New PrinterSensorHistory table + 60s recorder + GET/DELETE /printer-sensor-history
      route gated behind a new PRINTER_SENSOR_HISTORY_READ scope (separate from AMS). UI
      adds a 10x10 LineChart icon on each heater tile - click body opens the existing
      target-temp popover unchanged, click icon opens a HeaterHistoryModal mirroring the
      AMSHistoryModal shape (kind toggle + 6h/24h/48h/7d range + current/avg/min/max +
      recharts line for value + dashed target). Read-only X1C/P2S chamber tile finally
      gets an interaction. Retention configurable via printer_sensor_history_retention_days
      (default 30, sibling of ams_history_retention_days). 8 new i18n keys translated in
      all 11 locales, parity green. 4 backend + 6 frontend tests added; full pytest -n 30
      6226/6226, vitest 2176/2176, ruff/eslint/build all clean.
2026-06-28 12:37:49 +02:00
maziggy a70c2a2dc4 fix(usage-tracker): split mid-print AMS-Backup spool switch correctly (#1771)
Reporter forcefully started a print needing ~260 g with 180 g on the
      first spool and a backup spool in the AMS. Printer correctly consumed
      spool 1, AMS Backup switched, spool 2 finished the print. Bambuddy
      attributed all 260 g to spool 2 -- spool 1 untouched in inventory.

      Two stacking bugs produced the exact "all to second spool" symptom for
      prints without per-layer 3MF gcode data:

      1. bambu_mqtt.py:2135 wrote state.total_layers = int(data["total_layer_num"])
         unconditionally. P1S firmware pushes total_layer_num=0 at print end
         (same reset pattern other models do for layer_num / progress). The
         unconditional write clobbered the slicer's actual total to 0 before
         the usage tracker read it.

      2. usage_tracker.py:1129-1137 linear-fallback dumped EVERYTHING onto the
         last segment when total_layers was 0:
           if total_layers > 0:
               segment_grams = total_weight * (seg_end_layer - seg_start_layer) / total_layers
           else:
               segment_grams = 0.0   # <- entire print weight ends up on last segment

         Path 2 (AMS remain% delta) couldn't recover because (a) the emptied
         spool reported remain=-1 and (b) Bug-A had already added the second
         spool's key to handled_trays, suppressing the Path 2 lookup.

      Fix:

      - bambu_mqtt.py: only overwrite state.total_layers when the incoming
        value is positive (mirror of the existing _last_valid_layer_num
        pattern at line 2127). Explicit reset on new print start at
        _handle_print_start so the previous print's total can't bleed in.

      - usage_tracker.py: cascade the linear-fallback denominator -
        state.total_layers, then last_layer_num (already threaded in for
        the last_progress fallback), then equal-split as a bounded fence.
        Equal-split is still wrong but never dumps the whole print on the
        last segment, which was strictly worse.
2026-06-28 12:37:26 +02:00
maziggy 99c6949b5c feat(ams-backup): add status badge + toggle, fix prefer-lowest (#1766)
Two tightly-coupled deliverables in one drop -- a new AMS Filament Backup
      status/control surface, and the #1766 fix that depends on it.

      Added -- AMS Filament Backup status + control
      - Parse bit 18 of top-level print.cfg into PrinterState.ams_filament_backup
        on every push_status. Verified against OrcaSlicer source
        (DeviceManager.cpp:4961) and a live H2D ON/OFF capture. Tri-state
        (None = A1 family / pre-cfg push) preserves today's behaviour.
      - Hold-timer guard (3 s) prevents stale frames from flickering the badge
        back to the printer's old cfg after a user-initiated toggle.
      - POST /printers/{id}/ams-backup toggle, set_ams_filament_backup() client
        method calling _set_print_option("auto_switch_filament", enabled).
      - GET /printers/{id}/inventory-remain endpoint exposes the same map the
        dispatcher uses (internal and Spoolman modes both work uniformly).
      - Small icon badge in the printer card's "Filaments" section header
        (placement reads as printer-wide because the cfg bit is printer-wide,
        not per-AMS). Click to toggle, success toast.
      - 5 i18n keys x 11 locales for the badge UI.

      Fixed -- #1766: prefer_lowest didn't pick lowest, ignored backup state
      - Backend gate in _compute_ams_mapping_for_printer: coerce prefer_lowest
        to False when status.ams_filament_backup is False; log the skip.
      - New effectivePreferLowest(setting, backup) helper applied at every
        frontend sort entry point: single-printer PrintModal, multi-printer
        hook per-printer, PrinterSelector InlineMappingEditor, FilamentMapping
        standalone editor (the last had NO preferLowest awareness at all
        before this change).
      - New preferLowestSortKey(f, inventoryByTrayId) mirrors backend's two-tier
        key exactly, including the banding tie-break (regular AMS < AMS-HT <
        external) so the client-side pre-compute matches the dispatch-time pick.
        An earlier draft used a flat `amsId * 4 + trayId` priority which gave
        external slots (ams_id = -1) a NEGATIVE priority -- caught in code
        review before commit.
      - Settings -> Filament -> "Prefer lowest remaining filament" gets an
        explanatory note about the printer-side AMS Backup dependency, with
        i18n key in all 11 locales.
2026-06-28 12:37:00 +02:00
maziggy 5551087160 y fix(ams-history): respect theme background variant in stats modal
The AMS humidity/temperature stats modal hardcoded color literals
      gated on a light/dark boolean, so it ignored the active background
      variant (neutral / warm / cool / oled / slate / forest) and the
      light-bg variants. Switched modal chrome, stat cards, and the
      recharts grid / axes / tooltip to read --bg-secondary, --bg-primary,
      --border-color, --text-primary, --text-secondary, --text-muted from
      the active theme so the modal follows mode AND background variant.
2026-06-28 12:36:40 +02:00
maziggy 964015deaf fix(notifications): scope completion notification to printed plate on multi-plate 3MFs (#1785)
The 3MF parser sums prediction + weight across every plate (#1593) so the
      archive card can headline the whole project — correct for the card, wrong
      for the completion notification of a single plate. The queue UI already
      re-reads the 3MF per-plate at print_queue.py:272-285; mirror that for the
      notification path so Discord / Pushover / email show the plate's actual
      duration and grams instead of the project sum. Helper fails open on every
      error path so a missing or corrupt 3MF can't block the notification.
2026-06-28 12:36:20 +02:00
maziggy b691605509 fix(virtual-printer): forward H2C rack-swap nozzle pick from slicer to dispatch (#1780)
BambuStudio's project_file MQTT command for O1C2 (the H2C dual-
      nozzle-rack variant) carries nozzle_mapping (per-filament physical
      nozzle position IDs) and nozzles_info (per-extruder rack metadata).
      The VP intake was dropping both, so the H2C firmware fell back to
      "last matching nozzle type" auto-pick and ignored the user's
      slicer choice — every HF print landed on R2, every standard print
      landed on R4.

      Carry both fields through the VP intake → queue item → MQTT
      dispatch path. New nullable TEXT columns on print_queue, non-
      branched ALTER (matches ams_mapping / filament_overrides
      precedent). Dual-nozzle gate at start_print() keeps the fields
      off single-nozzle dispatches. Fail-open on malformed JSON —
      firmware auto-picks, never worse than pre-fix.

      Stamps both fields on every plate in the multi-plate Send All
      loop (#1697 / #1188 precedent).

      ams_mapping2 still handles H2D/X2D dual-extruder routing
      unchanged; this fix is scoped to the O1C2 rack-swap mechanism.
2026-06-28 12:36:02 +02:00
maziggy 580f42c1ec chore(deps): backend security floor bumps + 422 constant rename
requirements.txt
        - cryptography 46.0.7 -> 48.0.1 floor (GHSA-537c-gmf6-5ccf,
          non-contiguous Python buffer handling)
        - python-multipart 0.0.27 -> 0.0.31 floor (CVE-2026-53538/53539/53540,
          multipart parser hardening)
        - starlette 1.1.0 -> 1.3.1 floor (CVE-2026-54282/54283, FormParser
          limit enforcement + StaticFiles absolute-path rejection)
        - pyopenssl 26.0.0 -> 26.3.0 floor (NOT a security fix; pyOpenSSL
          <26.3.0 caps cryptography<47 and would otherwise downgrade out
          of the GHSA-537c-gmf6-5ccf fix line)

      backend/app/api/routes/mfa.py
        - 3x HTTP_422_UNPROCESSABLE_ENTITY -> HTTP_422_UNPROCESSABLE_CONTENT
          (the former is deprecated in starlette 1.3.x, same 422 wire status;
           the 2 remaining warnings are inside FastAPI itself, upstream's)

      Release-notes review done before bump: cryptography 47/48 dropped
      binary EC, CFB/OFB/CFB8, Camellia, PUBLIC_KEY_TYPES/PRIVATE_KEY_TYPES,
      OpenSSL 1.1.x, Python 3.8 -- grep clean against every removed surface;
      starlette's newly-enforced max_part_size=1MB only applies to text form
      fields (verified in MultiPartParser.on_part_data), file streams from
      UploadFile = File(...) are unaffected; python-multipart 0.0.30 dropped
      RFC 2231/5987 filename* parsing, minor cosmetic impact on non-ASCII
      filename uploads, plain filename= fallback still works.
2026-06-28 12:35:42 +02:00
maziggy 11227f65b3 chore(deps): dompurify 3.4.10 -> 3.4.11 (GHSA-cmwh-pvxp-8882, moderate) 2026-06-28 12:35:24 +02:00
maziggy 03d092387f fix(install): docker installer tries mkdir without sudo, escalates on EACCES (#1774)
install/docker-install.sh::create_install_dir ran `mkdir -p
      "$INSTALL_PATH"` without sudo while DEFAULT_INSTALL_PATH was
      /opt/bambuddy, root-owned on every Linux distro. set -e then
      aborted the whole script before docker compose could pull the
      image — anyone running the documented `curl ... | bash` flow as
      a normal user hit this on first install.

      Fix: try the unprivileged `mkdir -p ... 2>/dev/null` first so
      --path ~/bambuddy, /srv/bambuddy and other writable targets don't
      trigger a needless password prompt, then fall back to
      `sudo mkdir -p` + `sudo chown -R "$USER:$USER"` only when the
      first attempt failed. The chown is load-bearing: without it the
      script would later try to write docker-compose.yml + .env into a
      root-owned dir as the invoking user and cascade further EACCES
      failures.

      Not changing the default path: install/update.sh and
      install/update_macos.sh both default INSTALL_DIR to /opt/bambuddy,
      and install/README.md's update flow documents the same — flipping
      the install default to ~/bambuddy without coordinating those
      would silently break self-service updates for anyone following
      the docs verbatim. The default stays /opt/bambuddy; only the
      escalation gap closes.

      set -e survives the redirected stderr because the `if !` form is
      the documented escape hatch for an expected-failure check.

      Smoke-tested writable-target, idempotent-rerun, and the
      failing-mkdir-then-sudo-fallback branches.
2026-06-28 12:34:54 +02:00
maziggy d2232e0291 fix(archives): render plate thumbnails server-side when sidecar slice skips them (#1759)
Bambuddy's archive cards were blank for every print sliced through the
      BS or Orca docker sidecars. The "Some recent prints couldn't be archived
      with thumbnails" banner pointed at install step 4 which is unrelated —
      that flag only fires on FTP-fetch failures, not on missing-thumb in the
      sliced 3MF.

      Root cause is upstream of Bambuddy: neither slicer CLI renders
      Metadata/plate_N.png when invoked headlessly with --slice --export-3mf.
      That render is a separate code path triggered by --export-png, which is
      mutually exclusive with --export-3mf and additionally needs a working
      display backend (BS 02.07.x's bundled GLFW is hard-locked to Wayland —
      even XDG_SESSION_TYPE=x11 + GDK_BACKEND=x11 + QT_QPA_PLATFORM=xcb don't
      switch it back). An Xvfb display in the sidecar wouldn't help even if we
      wired the second-pass call. The Orca sidecar has been silently shipping
      thumbnail-less 3MFs from STL inputs since launch; nobody noticed.

      Fill the gap on the Bambuddy side: new plate_thumbnail.py renders the
      missing thumbnails after the slice returns. inject_plate_thumbnails_if_missing
      parses the sliced zip, finds every Metadata/plate_N.gcode entry that
      doesn't have a matching plate_N.png, loads 3D/3dmodel.model via trimesh,
      renders an isometric Bambu-green-on-dark view at 512x512 + 128x128 via
      the same matplotlib Agg pipeline as stl_thumbnail.py, and re-packs the
      zip with the PNGs injected. Visual style matches Bambuddy's existing
      library thumbnails — archive cards stay consistent inside Bambuddy rather
      than chasing parity with desktop Studio's plate render. Best-effort:
      input bytes are returned unchanged on any failure so the slice flow itself
      can never fail because of a missing thumbnail. Idempotent: re-running on
      an already-injected 3MF returns the input verbatim.

      Wired into both library.py slice paths via result._replace; covers the
      cross-class merged-multi-plate path automatically (merged bytes flow into
      the same write site). No sidecar Dockerfile change required — an earlier
      attempt to install Xvfb in Dockerfile.bambu-studio was a false start and
      is not part of this drop.

      Dependencies: trimesh's 3MF loader uses networkx (scene-graph traversal)
      and lxml (model.xml parse) lazily inside the 3MF code path — both added
      to requirements.txt because they aren't strict trimesh transitives.
2026-06-28 12:34:22 +02:00
maziggy b118dd2687 test(settings): include preset fields in /ui-preferences pin assertion
Follow-up to the temperature & fan-speed presets feature — the
      TestUiPreferencesEndpoint.test_returns_expected_field_set test pins
      the exact set of fields the endpoint exposes (so adding a sensitive
      field by accident fails the assert). The 4 preset fields were added
      to _UI_PREFERENCE_FIELDS without updating the pin, breaking the full
      backend test run.
2026-06-28 12:34:05 +02:00
maziggy 0128869d87 fix(printers): post-#1661 cleanup — test fixtures + remove hover-card fly-in
- The Speed and AMS load/unload tests broke after the printer-card
        refactor in #1661 (icon-only Gauge button replaced the "100%" badge,
        hover-card actions replaced the kebab "Slot options" button). Add
        data-testid="speed-control" + data-testid="filament-slot" as stable
        test hooks, rewrite both files around them. Parametrize the four-mode
        speed-selection test. Update the "RUNNING hides menu" assertion to
        "Load/Unload buttons exist but are disabled" — the new UX shows
        actions on hover and disables them rather than hiding the trigger.
      - Drop `animate-in fade-in-0 zoom-in-95 duration-150` from
        FilamentHoverCard and EmptySlotHoverCard. The card briefly painted
        at the offscreen (-9999, -9999) coords during the zoom-in transition,
        reading as a fly-in from the upper-left corner. With the animation
        gone it just appears in place at its computed position.
2026-06-28 12:33:50 +02:00
maziggy 946db27537 test(printers): repair speed + AMS load/unload tests after #1661 refactor
PR #1661 swapped the visible speed badge ("100%") for an icon-only Gauge
      button and replaced the kebab "Slot options" button with hover-card
      actions inside FilamentHoverCard. The two existing test files weren't
      updated alongside the refactor and stayed broken on dev.

      - Add data-testid="speed-control" to the Gauge button and rewrite the
        Speed tests around it; assertions on the percentage text are gone
        because that text no longer renders. Parametrize the four-mode API
        call test.
      - Add data-testid="filament-slot" to FilamentHoverCard's trigger
        wrapper and rewrite the AMS load/unload tests around
        fireEvent.mouseEnter → portaled actions. Replace the "hides menu
        while RUNNING" assertion with "Load/Unload buttons exist but are
        disabled" — matches the new UX shape.
2026-06-28 12:33:31 +02:00
maziggy 6fa74be429 feat: Update printer card UI for structure and readability (#1661) 2026-06-28 12:33:05 +02:00
maziggy 18270b4a69 log(scheduler): emit prefer-lowest dispatch decision at INFO so #1766 can be triaged from a bundle
The matcher's tray_info_idx vs color vs type_only bucket choice was
      debug-only, so a bug report's bundle never showed which path won. Emit
      the sorted candidate trays and the picked bucket per filament req when
      prefer_lowest=True. Behaviour-neutral; existing 89 matcher tests pass.
2026-06-28 12:31:58 +02:00
maziggy 8283b175c0 Restrict printer secrets to update-authority callers
GET /api/v1/printers/ and /api/v1/printers/{id} return access_code
      only when the caller holds PRINTERS_UPDATE. Adds PrinterResponseWithSecret
      as the elevated response shape; PrinterResponse no longer carries the
      field. Auth-disabled single-trust mode preserved.
2026-06-28 12:31:11 +02:00
maziggy 0eed98657f fix(local-presets): optimistic remove on delete
The Slicer -> Local Profiles page kept showing a just-deleted row for
      the ~hundreds of ms it took invalidateQueries to refetch. A quick
      re-click on the same row opened a second delete-confirm modal that
      resolved to a 404 from the backend.

      Add an optimistic queryClient.setQueryData filter in deleteMutation's
      onSuccess so the row disappears the instant the DELETE returns 200.
      Existing invalidateQueries calls stay in place to reconcile any drift.

      Found while reproducing #1713 (verifying maziggy's setup against the
      reporter's). Unrelated to that investigation but caught here.
2026-06-28 12:30:49 +02:00
maziggy f7620406cb chore(frontend): vite 7 -> 8 + plugin-react 5.2
Major version bump for the frontend build:
      - vite ^7.3.2 -> ^8.0.16
      - @vitejs/plugin-react ^5.1.1 -> ^5.2.0

      Vite 8 swaps Rollup for Rolldown as the default bundler
      (Rust-backed, same plugin contract). The bump also lifts the
      transitive esbuild floor to 0.28.1, closing the last open
      advisory in the audit chain.

      vite.config.ts surface audited and unchanged:
      - defineConfig, Connect type
      - serveGcodeViewer configureServer middleware
      - server.proxy with WebSocket upgrade for /api/v1/ws
      - build.outDir / emptyOutDir / chunkSizeWarningLimit
      - resolve.alias for @
      - base: '/' regression guard from #1221

      vitest@4.1.8 already accepts vite 8 in its peer range
      (^6 || ^7 || ^8); no test-runner bump required.

      Node floor for vite 8 is ^20.19.0 || >=22.12.0; CI Node 20.x
      line satisfies this.

      Not taken: plugin-react v6 — it requires
      babel-plugin-react-compiler and @rolldown/plugin-babel as
      peers and is a separate scope.
2026-06-28 12:30:24 +02:00
maziggy 000af6830b chore(frontend): dependency bumps
Runtime:
      - dompurify 3.4.0 -> 3.4.10 (package.json floor raised from
        ^3.4.0 to ^3.4.10 so fresh installs cannot land on the
        deprecated 3.4.4 release; release notes 3.4.1 -> 3.4.10
        reviewed — the three call sites (MakerworldPage,
        ProjectDetailPage, ProjectPageModal) use string-output
        sanitisation and are unaffected by 3.4.4's widened default
        allow-list)

      Build / lint / test tooling (transitive, dev-only):
      - @babel/core 7.29.0 -> 7.29.7 (via @vitejs/plugin-react and
        eslint-plugin-react-hooks)
      - vite 7.3.2 -> 7.3.5
      - markdown-it 14.1.1 -> 14.2.0 (via @tiptap/extension-link
        -> @tiptap/pm -> prosemirror-markdown; Bambuddy never calls
        markdown-it.render directly)
      - js-yaml 4.1.1 -> 4.2.0 (via eslint)
      - form-data 4.0.5 -> 4.0.6 (via jsdom)
      - ws 8.20.1 -> 8.21.0 (via jsdom)
2026-06-28 12:29:58 +02:00
maziggy 355d08a8f6 fix(spoolbuddy): inventory search matches spool ID + storage location (#1738)
The SpoolBuddy inventory page reimplemented its filter inline and only
      matched material/subtype/brand/color_name/note, while Bambuddy's main
      inventory uses the shared filterSpoolsByQuery helper which also matches
      spool ID, slicer_filament_name, and storage_location. Delegate to the
      shared helper so both pages stay in lockstep.

      - frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx: replace
        inline filter with filterSpoolsByQuery
      - frontend/src/__tests__/pages/SpoolBuddyInventorySearch.test.ts: lock
        in ID / partial ID / pre-fix fields / parity-gain fields
2026-06-28 12:29:29 +02:00
maziggy 37d5dfe25a Housekeeping 2026-06-28 12:29:03 +02:00
maziggy 0ebd354384 . 2026-06-28 12:28:16 +02:00
maziggy 7f15088615 fix(auth/ui): sidebar accepts granular *_read tiers for archives/queue/files (#1755)
navPermissions in Layout.tsx gated three resources on the LEGACY *:read flag.
      Default Operators group is seeded with *_own only (and the migration map flips
      legacy → _own on existing groups), so non-admin users never held the legacy
      permission and the sidebar hid Archives / Queue / Files even though the
      underlying API accepted their requests. Reporter only spotted Files; same bug
      shape applied to Archives and Queue.

      Fix: navPermissions accepts Permission | Permission[]; the three affected
      resources list all three tiers. isHidden checks .some(hasPermission) for
      arrays. Permission type extended with the matching *_own / *_all variants —
      backend already shipped them, the TS type just didn't declare them.
2026-06-28 12:25:38 +02:00
maziggy 2cbbd1eed3 fix(notifications): wire on_printer_offline dispatch on disconnect edge (#1752)
The provider toggle, schema, template, and NotificationService.on_printer_offline
      all shipped, but no caller invoked the dispatcher — the offline event was an
      orphan toggle. Edge detection in on_printer_status_change now schedules a
      debounced (60s) background task on the connected→disconnected transition;
      reconnect before the window elapses cancels it. Covers both upstream paths
      (smart-plug power-off via mark_printer_offline, and MQTT staleness via
      check_staleness), both of which already route through the status callback.
      The "back online" channel is the existing print-failure notification on
      firmware FAILED report — no symmetric on_printer_online needed.
2026-06-28 12:25:25 +02:00
maziggy ed1683fe3e fix(auth): preserve original URL across login + OIDC round-trip (#1750)
ProtectedRoute and PermissionRoute now pass the requested location as
      router state when redirecting to /login. LoginPage stashes it in
      sessionStorage before the OIDC provider redirect (since window.location
      kills React state) and consumes it on all three post-login navigations
      (credentials, 2FA, OIDC token exchange). Targets are sanitized to
      same-origin internal paths only — protocol-relative and /login itself
      are rejected to prevent open-redirect.

      QR labels (https://host/inventory?spool=N) now land on the scanned
      spool instead of the printer page after authentik / any OIDC SSO login.
2026-06-28 12:25:05 +02:00
maziggy 8faaeb96e0 fix(archives): backfill NULL created_at + tolerate NULL in response (#1732)
Older print_archives rows (and rows that landed via the SQLite ↔ Postgres
      cross-DB restore path) can have created_at = NULL because the column was
      originally created without a DEFAULT clause — server_default=func.now()
      only fires at table creation, not for existing rows or raw cross-DB
      inserts. The list_archives response model required a datetime, so a
      single NULL row 500'd the whole endpoint via Pydantic ResponseValidationError.

      - Boot-time backfill: COALESCE(completed_at, started_at, now()) for
        any row where created_at IS NULL. Dialect-branched (SQLite datetime('now')
        vs Postgres NOW()).
      - Schema: created_at is now Optional on ArchiveDuplicate, ArchiveResponse,
        and ArchiveSlim so a future NULL-leaking path doesn't break the list
        endpoint again.
2026-06-28 12:24:44 +02:00
maziggy 6aa04a4d05 Updated BACKERS 2026-06-28 11:46:17 +02:00
maziggy 140ae884dd Updated README 2026-06-28 11:20:22 +02:00
maziggy c1dca755d3 Updated BACKERS 2026-06-27 09:59:37 +02:00
maziggy 91e4219994 fix(inventory): assign-spool picker note visible on mobile (#793 follow-up)
The original #793 fix added the spool note as an HTML title= tooltip
on each picker button in AssignSpoolModal.tsx. title= only surfaces
on hover, which doesn't exist on touch devices — phone users tapping
a card just selected it, the note never appeared. Users who store
their tracking ID in the note field were blind on mobile (raised by
@EmcetPL on the closed issue).

Render the note as a small muted truncated line directly under the
weight on both the internal-inventory branch (line 436-ish) and the
Spoolman branch (line 510-ish): text-[10px] text-bambu-gray/70 mt-1
truncate, kept inside the truthy `&&` guard so empty notes don't add
a blank row. The existing title={spool.note} is preserved on the new
<p> so desktop hover and mobile long-press still surface the full
untruncated text for notes that overflow the truncate.

Mirrored across both inventory branches per the parity rule
(internal and Spoolman pickers stay shape-equal). No backend change,
no new state, no popover, no new touch target.
2026-06-27 09:56:15 +02:00
maziggy 93cae4dddd fix(auth): API keys with Manage Library can curate library files (#1832)
require_ownership_permission gates API keys on `all_perm` only — the
comment at auth.py:1659 says OWN and ALL "both map to the same scope
flag" for queue / archives / etc., so checking `all_perm` is the
correct gate. Library deliberately broke that: LIBRARY_UPDATE_OWN /
LIBRARY_DELETE_OWN mapped to can_manage_library, but the ALL variants
were in _APIKEY_DENIED_PERMISSIONS. Result — every API-key request to
DELETE /library/files/{id}, PUT /library/files/{id} (rename), or
POST /library/files/move hit "administrative operations" 403, even
for keys with can_manage_library=True. Only slice worked, because it
doesn't go through require_ownership_permission.

The "ALL stays admin-only because it crosses the user boundary"
intent was internally inconsistent. API keys have no per-row
ownership identity (user=None), so the route's
`file.created_by_id != user.id` ownership check would AttributeError
on a key acting under OWN anyway — the only working path is
can_modify_all=True, which `all_perm` denial blocked outright.

Fix folds LIBRARY_UPDATE_ALL and LIBRARY_DELETE_ALL into
_APIKEY_SCOPE_BY_PERMISSION under can_manage_library, matching the
can_queue precedent (QUEUE_UPDATE_OWN and QUEUE_UPDATE_ALL both
map to can_queue for the same per-key-identity reason). Both removed
from _APIKEY_DENIED_PERMISSIONS. LIBRARY_PURGE stays denied — it
bypasses the soft-delete window and is genuinely destructive.
2026-06-27 09:47:35 +02:00
maziggy d4ad41d850 fix(hms): action buttons actually reach the printer (#1830)
Three distinct bugs combined into one user-facing failure: clicking
Stop / Problem-solved-and-resume / Ignore-and-resume returned 200 OK
but the printer didn't act, modal stayed up, print stayed paused.
Verified by injecting candidate command shapes on device/<sn>/request
against a live H2D paused on a wrong-plate HMS (print_error=0x05008051).

(1) hms_resume / hms_stop dispatched the "err"-bearing shape that
BambuStudio doesn't actually send; Bambu firmware silently rejects it.
Both now send the plain shape ({"print":{"command":"<x>","param":"",
"sequence_id":"0"}}). PAUSE -> FAILED in 1.7s for stop, PAUSE -> RUNNING
in <2s for resume.

(2) IGNORE_RESUME mapped to idle_ignore, which is BambuStudio's
"dismiss a warning" command and only works for non-pause warnings.
hms_ignore now branches on state.state == "PAUSE": paused -> plain
resume; not-paused -> idle_ignore with the full-length err.

(3) 64-bit hms[]-array faults were truncated to a non-matching err.
short_code in _parse_status discarded 32 of the 64 identifier bits, so
the firmware didn't match it to the active fault. HMSError.full_code
now carries the canonical hex identifier (16 chars for hms[] faults,
8 chars for print_error faults). Catalog lookup tries 16-char first,
falls back to 8-char. HmsActionBody.print_error pattern relaxed to
^[0-9A-Fa-f]{8}([0-9A-Fa-f]{8})?$.

(4) execute_hms_action returned publish-success as success, masking
every silent-rejection bug above as 200 OK. Route now snapshots
(state.state, len(state.hms_errors)) before dispatch, awaits
HMS_ACTION_ACK_WAIT_SECONDS (default 2.5s, module-level so tests
override), and returns 502 with "Printer did not acknowledge HMS
action within 2.5s" if state didn't move.
2026-06-27 09:18:57 +02:00
maziggy 31e61cebd7 feat(sponsor-prompt): lower print/archive/cost thresholds to fire for typical new installs
The toast was calibrated for power users — lowest bars were 100 prints,
50 archives, 100 cost. Most installs never crossed any of them, especially
with the install base ~doubling since March. Matomo confirms: only 4
prints-100 and 3 archives-50 deeplink visits to /sponsors.html in a 7-day
window despite tens of thousands of weekly pulls.

Adds lower thresholds without changing priority order or cooldown:
  PRINT_MILESTONES   = (10, 25, ...)
  ARCHIVE_MILESTONES = (5, 10, ...)
  COST_MILESTONES    = (25, 50, ...)

Existing toast copy uses {count}/{total} interpolation in all 11 locales,
so no i18n changes. Tests rebalanced so "below the floor" still tests
with the new floor; new test_fires_at_lowest_threshold pins prints-10.
2026-06-26 16:48:22 +02:00
maziggy 3cb0433569 fix(printers): equalize external tray height with regular AMS slots
On dual-nozzle printers (H2C/H2D), the External card stacked a
separate "Ext-L" / "Ext-R" caption below each tray to mark which
extruder it fed. That caption appeared on the External card only,
making the bottom row of the printer card's AMS panel visibly
taller than the row above it.

Fix: the L/R distinction now lives inside the slot's colour circle
in place of the numeric index, and the bottom caption is removed.
FilamentSlotCircle's slotNumber prop is widened to `number | string`
to carry the letter. Single-nozzle externals (one tray, no L/R
distinction) keep the numeric "1".

The Ext-L / Ext-R strings still drive the slot's "location" label
in the filament hover card, so detail context is preserved.
2026-06-26 16:10:49 +02:00
maziggy 510005f043 fix(printers): cam wall — offline tile chip + don't kill shared
streams when one viewer closes

1) Offline tiles now show OFF (not LIVE)
   CameraWall.modeByPrinter assigned 'live' to any visible printer
   without considering status.connected, so a disconnected X1C wasted
   a live-budget slot AND rendered the red LIVE chip on top of the
   WifiOff placeholder. Disconnected printers now map to 'paused' and
   don't decrement liveBudget — the existing WifiOff + Off chip
   rendering takes over.

2) /camera/stop no longer kills other viewers' streams
   The cam-wall tile, EmbeddedCameraViewer, and the /camera/:id popup
   all subscribe to the same fan-out broadcaster for a printer.
   /camera/stop used to unconditionally shutdown_broadcaster() + kill
   every ffmpeg process for the printer, so closing the embedded viewer
   while the cam-wall tile of the same printer was live force-killed
   the source the tile was pulling from — the tile's <img> errored.

   New get_subscriber_count(key) accessor in camera_fanout.py exposes
   the broadcaster's subscriber list length. /camera/stop now reads
   that first; when >= 1 subscriber is still attached, return
   {stopped: 0, skipped: true} and leave the broadcaster + ffmpeg
   processes alone. The leaving viewer's HTTP teardown still runs the
   natural iter_subscriber.finally -> unsubscribe path, so its slot is
   released; the broadcaster keeps serving the other viewers. Single-
   viewer close still hits the immediate force-teardown (count is 0).
2026-06-26 16:01:28 +02:00
maziggy 6f727d300a Post work PR #1743 2026-06-26 14:59:29 +02:00
maziggy 35c98eece8 Updated README 2026-06-26 14:39:51 +02:00
maziggy d3fd8d6ad8 Updated README 2026-06-26 14:39:32 +02:00
maziggy 76d15d05c7 Updated CHANGELOG 2026-06-26 14:39:19 +02:00
maziggy 1c683f063c fix(queue): ownership gates + TOCTOU lock + /reorder validator (#1625-followup)
Three issues from the post-merge audit of the unified-dispatch PR, all
pre-existed on dev but became more impactful once every print routes
through the queue:

1. Start/Stop ownership gates. /queue/{id}/stop required QUEUE_UPDATE_ALL
   (admin-only) -- operators saw the Stop button in the queue UI but got
   403 on click. /queue/{id}/start required QUEUE_UPDATE_OWN with no
   ownership check -- _OWN holders could start anyone's queue items via
   direct API. Both routes now use require_ownership_permission, mirroring
   /cancel. Stop is strict (rejects unowned items for _OWN); start preserves
   #1670's VP-import flow where _OWN can start NULL-owner items and claim
   ownership at click-time. Frontend QueuePage Start/Stop buttons flip
   from printers:control to canModify('queue', 'update', created_by_id).

2. TOCTOU race on insert_position. Concurrent ASAP inserts to the same
   scope both computed MAX(position) from before the other committed; in
   an empty scope, both inserted at position=1 (duplicate). Wraps the
   read+update in a transaction-scoped Postgres pg_advisory_xact_lock
   keyed on the printer_id. Different printers don't contend. SQLite
   serializes writes implicitly so the path is no-op there. Dialect is
   checked against the live session binding, not the is_sqlite() helper,
   because the test fixture overrides get_db to SQLite while
   settings.database_url still points at Postgres.

3. /reorder duplicate-position validator. POST /queue/reorder set position
   from the payload in a loop with no uniqueness validation -- a buggy
   drag-drop client could leave the queue with ambiguous ordering (the
   scheduler's ORDER BY (printer_id, position) ties break by row order).
   New model_validator on PrintQueueReorder rejects duplicates at the
   schema layer with 422 + "Duplicate positions in reorder request: [N, ...]".
2026-06-26 13:06:40 +02:00
maziggy 0b43ac0d25 chore(deps): floor-pin pydantic-settings >=2.14.2 + msgpack >=1.2.1 for clean pip-audit
pip-audit flagged two advisories at the resolved versions in the venv.
  Neither is reachable in shipped Bambuddy, but the pins are taken so
  the audit stays clean and a future reachable advisory in either
  package isn't masked by existing noise.

  pydantic-settings 2.14.2 patches GHSA-4xgf-cpjx-pc3j —
  NestedSecretsSettingsSource with secrets_nested_subdir=True followed
  symlinks pointing outside the configured secrets_dir, reading
  out-of-tree files into settings values and bypassing the documented
  secrets_dir_max_size cap. Affected: >=2.12.0, <2.14.2. Bambuddy uses
  pydantic-settings only for env-var-backed config; the secrets-dir
  loader is not used (grep clean on NestedSecretsSettingsSource /
  secrets_nested_subdir / secrets_dir under backend/).

  msgpack 1.2.1 patches GHSA-6v7p-g79w-8964 — reusing an Unpacker
  instance after it caught an error can crash with SEGV, which is a
  DoS vector on untrusted input. msgpack is not a runtime dep of
  Bambuddy; it enters the tree only as a transitive of CacheControl,
  itself pulled by pip-audit (the very tool that surfaced the
  advisory). Pin placed in requirements-dev.txt next to pip-audit so
  it travels with the security-scan tooling rather than implying a
  runtime use.
2026-06-25 15:27:17 +02:00
maziggy c236fdc650 fix(auth): expose /api/v1/system/appliance through the auth middleware allowlist
The /system/appliance endpoint is fetched by the SPA's i18n bootstrap on
  mount to seed locale, hostname, timezone, and the chrony NTP-gate state
  BEFORE any login state exists. The route handler itself has no auth
  dependency and the test_route_auth_coverage allowlist correctly marks it
  public, but the global auth_middleware in main.py — which short-circuits
  every /api/ path not in PUBLIC_API_ROUTES — was never told about it.
  Result: every browser session on an auth-enabled install logged a 401
  on the appliance endpoint before login.

  Added /api/v1/system/appliance to PUBLIC_API_ROUTES with a comment
  pointing at the dual-list pattern so this doesn't drift again, and a
  regression test in TestAuthMiddlewarePublicRoutes that posts /auth/setup
  to turn auth on, then asserts the endpoint returns 200 with the
  documented shape (hostname / timezone / locale / time_synced fields all
  present).
2026-06-25 15:19:28 +02:00
maziggy 70857af393 feat(auth): SSO autologin + disable local username/password login (#1589)
Adds a global local_login_enabled setting plus a per-provider
  is_autologin flag on OIDCProvider so operators who run their own SSO
  enabled, or if the calling admin has no UserOIDCLink — either would
  lock everyone out. App-layer invariant: at most one provider can carry
  is_autologin; setting it on one clears it on every other.

  /auth/advanced-auth/status surfaces both new fields so the LoginPage
  decides UI in one query. The env-var bypass flips the reported
  local_login_enabled back to true so the SPA matches what the route
  will accept.
2026-06-25 14:54:27 +02:00
maziggy 43aeca02cb Updated README 2026-06-25 14:01:36 +02:00
maziggy f9bf145477 Updated README 2026-06-25 14:01:03 +02:00
maziggy b90dee02ab feat(printers): cam-wall view with on-screen live cap and snapshot fallback (issue #451)
New view toggle on the Printers page renders a responsive grid of live
  camera tiles instead of printer cards. Reuses the existing /camera/stream
  fan-out so the backend ffmpeg pipeline is unchanged.

  To stay sustainable on the median Pi 4 install, only on-screen tiles run
  live, and only up to a per-user cap (default 4). Other visible tiles
  fall back to periodic /camera/snapshot polling (default 8s). Off-screen
  tiles pause entirely. Tiles POST /camera/stop on unmount and on
  leave-live so the backend transcoder slot is released the same way
  EmbeddedCameraViewer does it.

  CameraTile is a 3-mode leaf (live / snapshot / paused) with a single
  <img> and an onError no-signal fallback. CameraWall is the scheduler:
  IntersectionObserver tracks visibility, a stable walker over the sorted
  printer list assigns live slots first-N-visible to avoid LRU churn. Same
  ['printerStatus', id] React Query cache the cards already populate, so
  flipping between Cards and Cam Wall is instant.

  Tile click honours the existing Settings camera_view_mode preference
  (window vs embedded). Both wall settings are per-user localStorage
  (camWallMaxLive, camWallSnapshotSec) — a Pi 4 user and a NUC user want
  different caps.
2026-06-25 13:58:34 +02:00
maziggy fd61812d01 feat(drying): show active-cycle filament + target temperature on the AMS drying badge
Bambu's per-tick AMS push carries only the dry_time countdown — the
  filament name and target temperature the user chose are never echoed on
  the wire. The AMS card had no source of truth for them and rendered the
  bare "Drying · 11h 35m left". The badge now shows
  "Drying · PETG @ 65°C · 11h 35m left", matching the cycle the user
  actually started.

  BambuMQTTClient caches {ams_id: {filament, temp}} on send_drying_command
  (mode=1), clears on mode=0 and on the dry_time falling edge to 0 — the
  same per-AMS edge detector that drives the smart-plug-after-drying
  callback. PrinterManager.get_drying_targets exposes it, the four
  printer_state_to_dict call sites thread it through, AMS schema gains
  dry_target_temp + dry_filament, and routes/printers.py builds the same
  fields into the manually-constructed AMSUnit response.

  When no cached target exists (drying started in a previous backend
  lifetime, or initiated outside Bambuddy), the badge falls back to the
  first loaded tray's tray_type + RFID-recommended drying_temp — the
  heuristic the popover already uses to seed defaults.

  i18n: printers.drying.targetSummary = "{{filament}} @ {{temp}}°C" in
  all 11 locales. Parity check 5356 leaves per locale.

  Note: a user reported the H2D's own physical display still labels the
  cycle by the loaded tray's filament (e.g. "PLA" instead of the
  Bambuddy-requested "PETG"). The wire payload is correct end-to-end —
  journalctl shows filament: "PETG" sent and result: success ACKed — and
  the badge in Bambuddy's own UI now reflects what we actually sent,
  independent of the firmware's display choice.
2026-06-25 13:27:32 +02:00
maziggy dac395231f Updated README 2026-06-25 12:49:35 +02:00
maziggy 8d6f701f1d feat(drying): continue drying while printing + gate rotate-spool when tray loaded (issue #1816)
Continue Auto-Drying while a print is running on capable hardware.
  New Settings > Print Queue > "Continue drying while printing" toggle
  (default OFF). Extends _check_auto_drying in print_scheduler.py to
  evaluate running printers when supports_drying_while_printing(model,
  firmware) returns true. Strict allowlist verified per Bambu wiki
  release notes for "Print While Drying" / "printing while filament is
  drying": H2D 01.03.00.00+, H2C/H2S/P2S/H2D Pro 01.02.00.00+, X2D/A2L
  01.01.00.00+, X1C 01.11.02.00+. P1*, A1, A1 Mini, X1 (non-C), X1E
  intentionally excluded. Mid-print drying temperature is capped at
  max(40, preset_temp - 5) to protect spools from heat damage inside the
  hot enclosure during a print, matching Bambu's own "lower drying
  temperature during printing" guidance.

  Rotate-spool toggle in the drying popover is now disabled when any tray
  in the targeted AMS has filament threaded into the feed tube
  (tray.state === 11). The whole AMS rotates as one mechanism, so a
  single loaded slot locks the entire unit. Previously the toggle was
  always clickable and the firmware rejected with dry_sf_reason=[3]
  (ConsumableAtAmsOutlet) after the click. The first cut keyed on the
  printer-level tray_now but missed the H2D's typical post-print state
  where tray_now resets to 255 while filament stays in the tube — the
  per-tray state field reports it correctly. Submission also clamps
  rotateTray off so a stale-true state from a previous AMS can't leak
  through.

  Backend: supports_drying_while_printing in printer_manager.py covers
  display names and internal SSDP/MQTT codes (O1D, O1E/O2D, O1C/O1C2,
  O1S, N6, BL-P001, N7, N9). New print_drying_enabled boolean in
  settings schema. Frontend: toggle on SettingsPage, gate + clamp on
  PrintersPage drying popover using existing amsData cache. i18n: 3 new
  keys x 11 locales, no English fallback. Tests: 7 cases on the gate
  matrix (TestSupportsDryingWhilePrinting), 4 cases on the scheduler
  mid-print path (TestMidPrintDrying), 9 cases on the rotate gate state
  transitions. Full backend pytest -n 30 green (4251/4251), ruff clean,
  frontend npm run build clean, i18n parity 5355 leaves per locale.
2026-06-25 12:47:26 +02:00
maziggy 50b7d498d9 Post work PR #1814
fix(db): order filament_shopping_list color_name ALTER after CREATE

  PR #1814 added ALTER TABLE filament_shopping_list ADD COLUMN color_name
  before the CREATE TABLE IF NOT EXISTS for that table. On fresh installs
  the ALTER hit "no such table" — not in _safe_execute's swallow list —
  and aborted run_migrations, breaking every migration test that starts
  from a fresh DB. Moved the ALTER to after the CREATE on both SQLite and
  Postgres branches; the CREATE already declares color_name, so this is
  purely the upgrade path and "duplicate column name" on re-runs is
  swallowed.
2026-06-25 11:48:10 +02:00
maziggy 5c673620f3 fix(notifications): false-positive Print Stopped on reprint after MQTT reconnect (#1807)
Reprints triggered a bogus "Print Stopped" push notification while the print
  kept running, surfaced by the reconciler synthesising a missed PRINT COMPLETE
  on MQTT reconnect.

  bambu_mqtt:3647 mints a fresh subtask_id per dispatch. On reprint, the
  on_print_start expected-archive promotion only wrote subtask_id when the
  stored value was empty (`not archive.subtask_id`) — so the archive kept the
  FIRST run's id. On the next MQTT reconnect, reconcile_stale_active_prints
  (#1542) compared the stale stored id against the printer's live id, found
  a mismatch, and synthesised a status="aborted" PRINT COMPLETE — which fires
  the "Print Stopped" notification.

  Captured cleanly in the reporter's support bundle:

    [RECONCILE] Printer 1: synthesising missed PRINT COMPLETE for archive 31
      — subtask_id changed ('1844213296' → '2103771517')

  immediately followed by gcode_state: RUNNING on the same wire.

  Fix: update archive.subtask_id whenever the new effective id differs from
  the stored one, not only when the stored one is empty. Inequality check
  preserves the noop-on-stable-push behaviour the original guard provided.

  Two places in main.py (expected-print and duplicate-printing-archive
  branches). 3 new unit tests cover the reprint, first-run, and stable-push
  paths. Reconciler itself unchanged — it was doing the right thing given
  the data it had.
2026-06-25 11:07:29 +02:00
maziggy e09a33be16 feat(spoolman): remain%-delta fallback for no-3MF "Untitled" prints (#1820)
Brings the Spoolman writer up to parity with the internal-inventory
  side, which has had this fallback since #1119. When a Bambu print
  starts without a retrievable .gcode.3mf on the printer (typically an
  unsaved BambuStudio project, subtask_name='Untitled'), Spoolman no
  longer silently skips the print's filament consumption.

  - ActivePrintSpoolman.filament_usage now nullable; new tray_remain_start
    column captures per-slot {remain, tray_uuid} at print start.
  - store_print_data: always snapshots remain, even when 3MF is present
    (mirrors usage_tracker.on_print_start), so partial-3MF prints can also
    fall back per-slot.
  - report_usage: 3MF path stays primary; new _report_remain_delta_for_slots
    handles slots the 3MF didn't cover via delta * Filament.weight / 100,
    resolving the spool via the existing slot-assignment table.
  - _report_partial_usage: same fallback for aborted no-3MF prints.

  #1119 invariant preserved: per-slot, per-print, gated on a valid
  start/current remain AND a resolvable Spoolman spool. Uses curated
  Filament.weight (not MQTT's unreliable tray_weight) — same trick the
  internal-inventory side uses.

  Mid-print spool swap detected via tray_uuid mismatch → slot skipped.
  Double-charge prevented via handled_global_tray_ids dedup.
2026-06-25 10:46:57 +02:00
maziggy 8a26e7d753 fix(inventory): stop popping the unknown-tag modal for slots with no RFID
The 7cb905a follow-up mounted the global unknown-tag modal listener, which
  turned an existing always-on broadcast for no-tag slots from a silent no-op
  into a perpetual popup loop — every push for a slot with a generic
  non-RFID spool (or zero-filled tag) re-prompted, and confirming each one
  created a fresh ghost spool with an empty tag.

  - main.py on_ams_change: drop the no-tag else-branch broadcast. No identity,
    no prompt; the slot stays unassigned until a real tag is read.
  - inventory.py + spoolman.py /spools/from-slot: 400 when the slot has no
    usable tag_uid / tray_uuid so stale frontends can't recreate the ghost
    spool by re-confirming a queued prompt.
  - test_inventory_from_slot_no_tag: lock the guard in (zero-filled + empty
    string).
2026-06-25 09:57:15 +02:00
maziggy 261c376d1f fix(spoolbuddy): close #1815 — preserve PFUS/PFCN setting_id in resolver
SpoolBuddy "Assign to AMS" with a Bambu Cloud user preset (PFUS) left
  Bambu Studio showing "Generic <Material>" instead of the user's
  custom preset. Root cause: the defensive filter that catches
  PFUS/PFCN leaks into tray_info_idx also cleared setting_id —
  but PFUS/PFCN are VALID setting_id values, just not valid
  tray_info_idx values. When the cloud detail lookup didn't return
  a filament_id (cloud unauth on the on_ams_change replay path,
  transient failure, or older custom presets), both fields got
  cleared and the caller's generic-material fallback overwrote
  setting_id with GFSG99 — slicer resolved to Generic PETG.

  Fix: the filter still clears tray_info_idx for PFUS/PFCN/material-
  name leaks, but preserves setting_id when it's a valid slicer
  reference (PFUS / PFCN / GFS). Material-name leaks still clear
  both. Post-fix MQTT carries tray_info_idx=GFG99 (firmware-acceptable
  for HMS/drying/colour) AND setting_id=PFUS<hash> (slicer uses this
  to load the actual user preset).

  What stays the same: Bambuddy's own AMS card still displays
  the generic material on cloud-unauth paths — same fundamental
  limitation as today. Fixing that needs a deeper layered fallback
  (LocalPreset name match, printer kprofile query, cloud-detail
  cache) and is out of scope for this drop. Slicer-side fix is
  the reporter's explicit ask.
2026-06-25 09:26:33 +02:00
maziggy 4b0150b0ec fix(mqtt): close #1822 — promote H2S tray_now to 254 on all-external prints
H2S firmware reports tray_now=0 (the AMS's idle slot) throughout
  external-spool prints instead of 254 like X1C/P1S/A1 do, so the
  single-nozzle branch's 0-3 passthrough landed state.tray_now on slot
  0 — UI highlighted AMS SLOT 1 instead of the external spool.
  Usage credit was unaffected (#1276 covers that via ams_mapping).

  The single-nozzle branch now checks _captured_ams_mapping (slicer-
  captured per-filament mapping that the request-topic intercept
  already tracks) before the existing P2S multi-AMS resolver. When
  every entry is -1, the print uses ONLY the external spool, so
  state.tray_now is promoted to 254.

  Narrow on purpose: AMS-only [5] and mixed [5, -1] are NOT
  overridden — we have no evidence H2S misreports mid-print swaps, and
  trusting the firmware preserves correctness for users with multi-
  filament setups. No-mapping prints (printer-screen start) fall
  through unchanged.
2026-06-25 09:11:23 +02:00
maziggy 2fe9896917 fix(queue): close #1818 — Resume after failure clears the gate
Single failure on a printer with require_previous_success queue items
  permanently skipped every downstream + every new item — the
  _check_previous_success lookback always walked back to the original
  failed row (skipped is excluded from the lookback), and no code path
  could dismiss that failure.

  Three pieces:

  1. PrintQueueItem.gate_acknowledged Boolean column (default False).
     SQLite/Postgres-safe ALTER, dialect-branched DEFAULT.

  2. _check_previous_success skips rows where gate_acknowledged=True so
     acknowledged failures walk past the lookback. Fresh post-resume
     failures still gate independently.

  3. POST /api/v1/queue/printer/{printer_id}/resume — gated on
     QUEUE_UPDATE_ALL — acknowledges failed/aborted items for that
     printer AND restores items where
     status='skipped' AND error_message='Previous print failed or was
     aborted' back to pending in one transaction. Returns
     {acknowledged, restored}.

  Frontend banner above the active Queue tab surfaces blocked printers,
  fires a warning-variant ConfirmModal, and shows a precise toast on
  success.
2026-06-25 09:00:57 +02:00
maziggy 5c9c49c35b fix(archives): correct #1812 — Step 4 wiki link host
Banner pointed to bambuddy.cool/wiki/getting-started/... which 404s;
  the wiki lives under the wiki.bambuddy.cool subdomain. Anchor was
  correct (MkDocs slug matches the existing "Step 4: Enable Store sent
  files on external storage" heading).
2026-06-25 08:30:50 +02:00
maziggy 38b8a87c11 fix(vp): close #1780 race — bump slicer-MQTT wait to 5s + retroactive stamp
Round 2 (166e9f9e) fixed the stash-key mismatch, but @mkoreen's
  2026-06-23 bundle showed BS's MQTT project_file arrived 85 ms past the
  2.0 s wait timeout (FTP done 00:42:02.509, "No slicer options cached"
  00:42:04.509, MQTT 00:42:04.594). Queue item was committed with
  settings defaults; nozzle_mapping never made it onto the wire.

  Three pieces:

  1. _SLICER_OPTIONS_WAIT_TIMEOUT module constant, 2.0 -> 5.0 s. Covers
     wireless / loaded-Pi jitter; one-time +3 s cost only for legacy
     slicers that never send MQTT.

  2. _RECENT_QUEUE_ITEM_TTL fallback: on_print_command retroactively
     UPDATEs slicer-driven fields on a recently-committed queue item
     when the event wait already gave up. Tracked via
     _recent_queue_items dict (30 s TTL, evicted on every queue-add).
     Gated on status='pending' so we never race the dispatcher.
     Multi-plate covered via WHERE id IN (...).

  3. Post-commit last-chance pop. Audit caught a race in (2): MQTT could
     arrive during any await inside _add_to_print_queue (wait_for,
     archive_print, db.flush, db.commit), and on_print_command would
     stash data with no event consumer AND no _recent_queue_items entry
     yet. After populating _recent_queue_items, _add_to_print_queue now
     pops _slicer_print_options[file_path.name] one last time and
     routes any hit through _restamp inline.
2026-06-23 12:33:02 +02:00
maziggy fb3821630f feat(inventory): batch / mass edit on the Filament tab (#1795)
Bulk operations on the Inventory page in both built-in and Spoolman modes.
  Reporter wanted ten-of-the-same-spool edits without ten round-trips through
  the per-spool editor.

  Frontend
  - New checkbox column on the inventory table (header / row / group). Sticky
    toolbar appears when at least one row is selected with Edit / Print labels /
    Reset usage / Archive (or Restore in the Archived tab) / Delete / Clear.
    Selection clears on any filter / tab / search change so the count can't
    drift from what is on screen.
  - BulkEditSpoolsModal is a three-state-per-field form. The user opts in per
    field by ticking its checkbox or just typing into it; only ticked + non-
    empty fields are sent. Clearing fields in bulk is intentionally NOT
    supported per the issue discussion.
  - A new SearchableSelect renders all categorical fields (material, sub-type,
    brand, category, slicer preset name, slicer filament, storage location)
    with the same dropdown pattern the per-spool editor uses - text input +
    chevron + filtered button list, click-outside / Escape closes. No native
    select anywhere in the modal. Options merge the canonical constants from
    spool-form/constants.ts with whatever already exists in the user's
    inventory. Slicer-preset dropdowns fetch the same sources as the per-spool
    form (Bambu Cloud + Orca Cloud + local + built-in) through buildFilament
    Options() and three useQuery calls gated on isOpen.
  - onSuccess handlers surface three outcomes: all-succeeded (green toast),
    partial-success (yellow toast with ok / failed counts), all-failed (red
    toast that keeps the selection and modal open so the user can retry).
    The first cut silently dropped errors / not_found arrays - audited and
    fixed before merge.
  - Invalid rgba hex is flagged inline with a red border + helper text and
    the Apply button is gated on a hasDroppedTickedField guard, so silently
    dropping a ticked field is no longer possible.
  - bulkResetConsumedCounterMutation.onSuccess now closes the confirm modal +
    clears selection, matching the other three bulk mutations.

  Backend
  - Four new endpoints per inventory mode (eight total):
      POST /api/v1/inventory/spools/bulk-update         INVENTORY_UPDATE
      POST /api/v1/inventory/spools/bulk-delete         INVENTORY_UPDATE
      POST /api/v1/inventory/spools/bulk-archive        INVENTORY_UPDATE
      POST /api/v1/inventory/spools/bulk-restore        INVENTORY_UPDATE
      POST /api/v1/spoolman/inventory/spools/bulk-*     FILAMENTS_UPDATE
  - Built-in update runs the same prepare_internal_spool_payload(...) +
    weight_used / weight_locked auto-stamp as the per-spool PATCH.
  - Spoolman update loops the per-spool update_spool route function so the
    filament re-linking / extra-dict / extra-lock / shared-filament rules
    stay byte-identical to single-spool edits.
  - Per-spool failures inside the batch are collected. Spoolman bulk-delete /
    archive / restore now catch non-HTTPException too (matches bulk-update) -
    a mid-batch httpx.ConnectError or TimeoutError no longer aborts the route
    with a 500 and skips the WS broadcast.
  - Both modes broadcast a single inventory_changed WS event at the end of
    the batch.
2026-06-23 11:34:15 +02:00
maziggy 7cb905ad0c feat(inventory): toggle to disable auto-add of unknown RFID spools + global confirmation modal (issue #1764)
New setting "Auto-add unknown RFID spools" under Settings -> Filament -> Filament Tracking,
  default ON for back-compat. When turned off, the backend stops auto-creating an inventory
  record for an unknown RFID tag and instead broadcasts an unknown_tag WS event that pops
  a global confirmation modal in the Bambuddy UI showing the printer / AMS-X label / slot /
  material / colour. Add or Cancel; no nag on every MQTT push.

  Backend
  - Module-level _unknown_tag_last_broadcast dict dedupes per (printer, slot, tag). Set is
    committed AFTER ws_manager.broadcast() returns so a crashed broadcast doesn't poison
    the dedup and permanently silence the slot.
  - Empty-slot MQTT push clears that slot's entry, so remove+reinsert reliably re-prompts.
  - Successful matches via get_spool_by_tag / find_matching_untagged_spool / create_spool
    also clear the entry so a future tag swap re-prompts.
  - Tray data (tray_type, tray_color, tray_sub_brands, tray_count) shipped in the WS payload
    directly so the modal renders the real material / colour instead of relying on the
    React Query cache that lags the WS event by several seconds.
  - Two new endpoints back the modal's confirm action:
      POST /api/v1/inventory/spools/from-slot     (INVENTORY_UPDATE)
      POST /api/v1/spoolman/spools/from-slot      (FILAMENTS_UPDATE)
    Both look up the slot's tray data server-side and create + auto-assign atomically.
  - Spoolman /from-slot now raises HTTP 500 when the slot-assignment INSERT fails instead
    of returning success while the DB rolled back the binding.
  - sync_ams_tray gained an optional auto_add_unknown_rfid kwarg (default True so existing
    callers are unaffected); auto-sync and both manual sync routes thread the setting.

  Frontend
  - useUnknownTagPrompt hook listens for the unknown-tag CustomEvent, reads the tray fields
    out of the event detail, and feeds a single-modal queue. No long-lived dismissed set;
    the backend dedup handles spam suppression.
  - UnknownSpoolModal wraps the existing ConfirmModal with a material + colour-swatch
    preview block.
  - Mounted in Layout.tsx alongside useSponsorPrompt so SpoolBuddy kiosk / login / setup
    routes are excluded.
  - getAmsLabel moved to utils/amsHelpers.ts; ConfigureAmsSlotModal.tsx and PrintersPage.tsx
    both import the shared version (canonical AMS-A / HT-A / External labels).
  - AppSettings TS interface gained spoolman_enabled, auto_add_unknown_rfid, spoolman_url
    so the runtime cast in the hook is no longer needed.
  - SpoolmanSettings.tsx gets a new toggle row in the Filament Tracking card, visible in
    both built-in and Spoolman branches; auto-save + toast already wired.
2026-06-23 09:59:05 +02:00
maziggy 50a4c4c3eb Post work PR #1798 2026-06-22 14:46:24 +02:00
maziggy 4dcd37bc87 feat(system): NTP-gate state on /api/v1/system/appliance
Extends the appliance endpoint that landed in the previous commit with a
  time_synced field, sourced from /run/bambuddy/time-synced (the appliance's
  ntp-gate.sh writes this once chronyd reports sync, or with a "warning"
  marker after the 3-minute timeout). The RPi 5 has no battery-backed RTC,
  so on a fresh boot the system clock is wrong until NTP catches up -- JWT
  expiries and TLS certificate validity windows depend on this being right.
  Exposing the gate lets the SPA render a "time not synced" indicator while
  that's still true and clear it once "ok" comes through.

  backend/app/core/local_config.py

  New read_ntp_gate(path) function alongside read_local_toml. Three states:

    "ok"       chrony reported sync within the 3-minute window
    "warning"  3-minute timeout elapsed without sync; user already waited
               and the wizard proceeded with a degraded clock
    None       file absent (non-appliance install), OSError, empty content,
               unknown marker, or binary garbage -- "unknown / don't gate"

  Defensive read mode (errors="replace") survives non-utf8 content without
  crashing. Module docstring broadened from "local.toml reader" to "small
  readers for appliance-set state files".

  backend/app/api/routes/system.py

  /system/appliance now returns:

    {hostname, timezone, locale, time_synced}

  with the same no-auth posture: bootstrap surfaces (i18n init, time-sync
  banner) read this before auth might be set up, and the contents are
  non-secret (user-set defaults + a public sync flag). The endpoint
  docstring expands to explain the RTC motivation -- otherwise the
  time_synced field reads like a leftover.
2026-06-22 14:23:30 +02:00
maziggy f4a4d6dceb feat(system): appliance locale defaults endpoint + frontend i18n bootstrap
Closes the cross-repo contract started in bambuddy-appliance: the firstboot
  wizard writes /etc/bambuddy/local.toml with the user's hostname / timezone /
  locale, but nothing on the main app side read it. Hostname + timezone are
  already applied by the appliance's firstboot.sh via hostnamectl /
  timedatectl. This PR closes the loop for the third field — locale — so the
  language the user picked in the wizard actually shows up on first SPA load.

  backend/app/core/local_config.py

  New module. read_local_toml(path) returns a LocalConfig TypedDict
  ({hostname?, timezone?, locale?}) parsed from /etc/bambuddy/local.toml.
  Defensive on every failure mode -- missing file returns {}, invalid TOML
  returns {} + log warning, non-string values dropped with warning. The
  reader never raises; a malformed config never blocks startup.

  backend/app/api/routes/system.py

  New endpoint GET /system/appliance. Returns {hostname, timezone, locale}
  with null for any field not present in the TOML. No auth required: the
  frontend i18n bootstrap reads this before auth might be set up, and the
  contents are user-set defaults, not secrets. The function calls
  read_local_toml() with no args (default path) so tests can monkeypatch
  the module's read_local_toml reference to inject fixtures.

  frontend/src/i18n/index.ts

  One-shot applyApplianceLocale() runs after i18n.init(). Gated by a
  bambuddy_appliance_locale_consumed localStorage flag so it runs at most
  once per appliance. Fetches /api/v1/system/appliance, validates the
  returned locale against supportedLngs, calls i18n.changeLanguage if
  valid. Silent .catch() because the endpoint absent / unreachable means
  non-appliance install or dev environment -- we leave the LanguageDetector's
  choice in place. The consumed flag is set on success; future loads skip
  the fetch entirely. Won't override a user's explicit language pick (the
  language picker writes to a separate localStorage key, bambutrack_language).
2026-06-22 14:13:44 +02:00
maziggy bb42b423af feat(file-manager): user-authored tags for cross-cutting filtering (#1268)
Third and final piece of #1268, alongside the recursive-search +
  README-panel commit that landed earlier in 0.2.5b1. Folders express
  hierarchy (one home per file); tags are orthogonal labels — "toy",
  "kid-safe", "petg-only" — and a single file can carry as many as the
  user wants. Reporter wanted to find "every toy regardless of which
  folder it lives in"; folders alone can't do that without forcing the
  file into one bucket.

  Design decisions locked with maziggy before code:

    - file-only (folders already express hierarchy)
    - multi-tag filter = AND
    - tag filter IGNORES the selected folder (cross-cutting by design)
    - bulk-tagging from multi-select toolbar in v1
    - no auto-tags from 3MF metadata (user-authored only)
    - label-only chips, no color/icon

  Backend

    - LibraryTag (id, name, name_key UNIQUE = LOWER(TRIM(name)))
      in backend/app/models/library.py. Case-insensitive UNIQUE
      collapses "Toys"/"toys"/"TOYS  " into one row, so the route
      returns 409 instead of silently fragmenting the catalog.
    - LibraryFileTag(file_id, tag_id) association, composite PK,
      ON DELETE CASCADE both directions. Deleting a tag drops every
      chip; files survive. Deleting a file drops its tag links; the
      catalog row survives.
    - Both tables auto-create via Base.metadata.create_all — no
      explicit run_migrations step needed for new tables.
    - New router at backend/app/api/routes/library_tags.py with:
        GET /library/tags         (list + per-tag file_count)
        POST /library/tags        (create, 409 on case-insensitive dup)
        PATCH /library/tags/{id}  (rename, 409 on collision, self-rename OK)
        DELETE /library/tags/{id} (cascade)
        POST /library/tags/bulk-assign  (add | remove | replace)
    - Bulk-assign add is idempotent; replace with empty tag_ids clears
      the file's tag set. Per-file ownership enforced — *_OWN callers
      can only modify their own files; unknown file_ids quietly
      skipped (matches library_trash bulk shape).
    - list_files gains tag_ids: list[int] query param. AND semantics
      via JOIN + GROUP BY + HAVING COUNT(DISTINCT) — portable across
      SQLite and Postgres. When tag_ids is non-empty, folder_id /
      project_id / include_root / recursive are all bypassed so the
      result is cross-cutting.
    - FileListResponse gains tags: list[{id, name}] via
      selectinload(LibraryFile.tags) — N+1-free chip render.
    - Permissions reuse existing constants: LIBRARY_UPDATE_ALL for
      catalog mutations (global catalog, ownership-aware update isn't
      meaningful), LIBRARY_UPDATE_ALL/OWN pair for bulk-assign,
      LIBRARY_READ_ALL/OWN for list — file_count projection narrows
      for *_OWN callers so chip counts match what they actually see.

  Frontend

    - LibraryTagsModal (catalog CRUD) opens from the toolbar's new
      Tags button. max-w-4xl so multi-language subtitles don't wrap.
      Delete-with-warning when file_count > 0 ("removes the chip from
      all of them; files themselves are untouched").
    - BulkTagsPickerModal opens from the multi-select toolbar (new
      Tag button between Move and Delete). Add/Remove radio,
      checkbox list, inline "create new tag" disabled on dup.
      Apply disabled until at least one tag is selected. The replace
      action is exposed in the API but deliberately NOT in this UI —
      arbitrary multi-file replace is destructive and confusing.
    - FileManagerPage integration:
        * selectedTagIds state, sorted into the useQuery key so the
          cache hits are stable regardless of toggle order
        * filter rail above the file list lists EVERY catalog tag as
          a togglable chip — inactive outlined, active filled green
          with an X. Clear all when 1+ active. Bar hidden entirely
          when catalog is empty.
        * useEffect prunes selectedTagIds when a tag is deleted from
          the catalog so the filter never strands on a phantom id
        * dedicated Tags column in list view at minmax(0,200px)
          between Prints and Actions
        * grid view chips render below the metadata block
        * chip clicks stop propagation so they don't toggle file
          selection
    - libraryTagsQueryKey extracted to frontend/src/utils/
      libraryTagsQuery.ts so component files export only components
      (Vite react-refresh rule).
    - LibraryFileListItem.tags is OPTIONAL even though the backend
      always emits an empty array — legacy msw mocks in pre-existing
      tests construct partial file shapes without the field. Without
      the ? the FileCard renderer crashed on .length and broke 49
      unrelated tests across FileManagerPage + FileManagerExternalFolder.
      Read sites use file.tags ?? [].
2026-06-22 12:27:58 +02:00
maziggy 5cbefca6a0 feat(file-manager): recursive subfolder search + per-folder markdown description panel (#1268)
Reporter (@zumik3-del, seconded by @unLieb) asked for three File Manager
  improvements: recursive search, tags, and a markdown preview side panel.
  This commit ships the two scoped ones; tags is held back gated on the
  "give the issue a thumbs up" interest check Martin posted on the issue
  because it's a much larger surface (M2M schema, CRUD endpoints, tag UI +
  filter + autocomplete + i18n for the management surface) and isn't the
  right call without a real demand signal.

  1) Recursive search inside the selected folder.

     Until now, selecting "Toys" and typing "robot" only found files
     directly in Toys/ — anything under Toys/Cars/Race/ stayed invisible.
     The page's client-side filter ran over a server-narrowed listing
     (/library/files?folder_id=X is strict equality on folder_id), so the
     client filter couldn't see what the listing never loaded.

     list_files (backend/app/api/routes/library.py:1729+) gains a
     recursive=true query param. When combined with folder_id, the route
     walks library_folders.parent_id via a recursive CTE rooted at the
     requested folder and returns every descendant folder's files in one
     query. Recursive CTEs work on both SQLite >=3.8.3 (2014, well below
     Bambuddy's runtime floor) and Postgres without dialect branching.
     Default off so the existing folder-browsing call sites (Project /
     Archive detail, the FE's no-search case) keep their narrow scope.

     FE opts in only when both a folder is selected AND searchQuery is
     non-empty (FileManagerPage.tsx — derived as searchExpandsSubfolders,
     threaded through the useQuery key so the cache invalidates on
     toggle). Small "Including subfolders" caption renders under the
     search input when active so the user understands why a file from two
     levels deep showed up.

  2) Per-folder markdown description panel.

     New endpoint GET /library/folders/{folder_id}/readme returns the
     first .md file in the folder as {filename, content, truncated}.
     Selection prefers README.md / readme.md / description.md
     (case-insensitive via func.lower(filename) LIKE '%.md' + an
     in-Python stem-preference sort), falls back to the
     alphabetically-first *.md otherwise. 404 when no markdown is present
     so the FE can hide the side panel — non-users pay no UI cost.

     Bytes are clipped at 512 KiB (_README_BYTES_CAP) with a truncated
     flag so the panel can warn the reader. UTF-8 decode uses
     errors="replace" so one bad byte never blanks the panel.

     New FolderReadmePanel.tsx fetches on folder-select and renders via
     react-markdown@9 + remark-gfm@4 (tables, strikethrough, task lists).
     Collapsible (default expanded), max-height 24rem with internal
     scroll. react-markdown 9 doesn't render raw HTML by default — no
     dompurify needed. Links open in a new tab with rel=noopener
     noreferrer. Tailwind has no typography plugin in this project so
     per-element components map h1/h2/h3/p/ul/ol/code/blockquote/table
     to explicit utility classes that match the rest of the app.

  Scope and permissions.

  Both endpoints reuse the existing LIBRARY_READ_ALL / LIBRARY_READ_OWN
  ownership-aware pair, so a viewer-tier user with read_own only sees
  their own files in recursive listings and can only fetch the README of
  folders containing their own files. No new permission, no DB migration.

  The recursive CTE is a single SQL query — no N+1, no per-folder
  round-trip, scales to deeply-nested model libraries.
2026-06-22 11:40:58 +02:00
maziggy 7e6b390d74 feat(notifications): template-driven finish-photo email embed + user_print_* rename (#1792)
Reporter (email provider, "Reason: unknown" failures) wanted a camera snapshot
  in failure emails. The finish-photo capture path shipped in 0.2.5b1 (#1397)
  already loads JPEG bytes into archive_data["image_data"] for terminal print
  events, and pushover/telegram/discord/ntfy users have been getting them.
  Email was the one provider that dropped the bytes on the floor. Reporter
  separately flagged that the Message Templates list shows "Print Completed"
  and "User Print Completed" with no visual cue they're different dispatches.

  Both fixes in one commit because they touch the same UI surface (Message
  Templates) and both stem from the same reporter conversation.

  1) Inline finish-photo embed in email — template-driven, opt-in.

     _send_email now accepts finish_photo_url alongside image_data. Inline
     embed fires only when bytes are present AND URL is set AND the rendered
     body contains that URL — i.e. the user's template referenced the existing
     {finish_photo_url} variable. The multipart/related shape wraps a
     multipart/alternative (plain + HTML) plus an inline MIMEImage with
     Content-ID: <bambuddy-finish-photo>. The HTML part replaces the escaped
     URL in-place with the cid <img>, so the image appears WHERE the user put
     the variable in the template, not stapled to the bottom. Plain-text part
     keeps the URL as a clickable link for non-HTML clients.

     First draft of this fix unconditionally inlined the photo whenever
     image_data was present, which bypassed the template system. Reverted to
     the template-driven contract: default templates unchanged, opt-in by
     editing the template body to include {finish_photo_url}.

  2) user_print_* template name disambiguation.

     The four user_print_* templates are the per-user SMTP emails sent to the
     print's submitter (advanced-auth-only path). They shared the "Print
     Completed" / "Print Failed" / etc. short names with the broadcast
     provider templates, so the Message Templates list was indistinguishable.
     The EVENT_NAMES display map in routes/notification_templates.py already
     used the disambiguated "… Email" labels, but the seed wrote the short
     name to the DB.

     DEFAULT_TEMPLATES now seeds the four user_print_* rows with " Email"
     suffix so fresh installs are correctly labelled. New
     _migrate_rename_user_print_template_names runs on startup and updates
     existing rows where the name still matches the old default. Admin-edited
     names are preserved. Standard SQL UPDATE works on both SQLite and
     Postgres without dialect branching.
2026-06-22 11:15:39 +02:00