Commit Graph
350 Commits
Author SHA1 Message Date
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 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 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 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 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 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 8e99b0c86d Fix camera port diagnostic for A1/P1 printers (#1799) 2026-06-28 12:43:02 +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 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 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 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 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 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 6fa74be429 feat: Update printer card UI for structure and readability (#1661) 2026-06-28 12:33:05 +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 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 37d5dfe25a Housekeeping 2026-06-28 12:29:03 +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 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 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
Ed 4c67d8a4e1 feat: Unify print dispatch through the scheduler (#1625) 2026-06-26 12:31:48 +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 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 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 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 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
BambuMan 57e312b38c feat(inventory): by-tag spool lookup, readable with Manage-Inventory keys (#1663) (#1700) 2026-06-22 10:14:31 +02:00
maziggy 4d16faed76 feat(file-manager): sort folder tree by recent activity (#1770)
Reporter has a lot of nested cad / slicer directories and wanted
  "folders that just got a new 3MF" surfaced without scrolling the
  alphabet. Tree was always alphabetical; LibraryFolder.updated_at
  only bumps on rename / move, not on file-add inside the folder.

  Backend exposes latest_activity_at = max(folder.updated_at,
  max(immediate-child file.updated_at)) on FolderResponse +
  FolderTreeItem. The /folders tree route picks up a sibling
  func.max(updated_at) group-by alongside the existing file-count
  subquery; the by-project / by-archive / single-folder routes
  collapse count + max into one trip. Recursion across subfolders
  is intentionally not computed - bubbles immediate parent only,
  keeps the query a single GROUP BY rather than a recursive CTE.

  Frontend adds a folder-sidebar sort dropdown (By name / By recent
  activity) plus an asc / desc arrow, persisted in localStorage.
  sortedFolders memo applies the comparator recursively so order is
  consistent at every depth. Empty folders fall back to name within
  the activity bucket so they never elbow a recently-used folder to
  a random position. Both the desktop sidebar and the mobile selector
  consume the sorted list so order is identical across breakpoints.

  External folders: LibraryFile rows are created for scanned external
  files too, so the aggregate works on them - but the timestamp
  reflects last scan, not filesystem mtime. Documented in the wiki.

  Same change also fixes File Manager list-view column alignment:
  header and body were sibling grids with min-content as the trailing
  column, computed independently. Header empty trailing div resolved
  to 0; body action strip to ~220px. Different trailing widths gave
  the 1fr Name column different remaining space, shifting every fixed
  column to its right. Replaced min-content with fixed 220px in both
  auth-on / auth-off grid templates.
2026-06-21 13:31:50 +02:00
maziggy 68b9d741d9 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-21 11:55:37 +02:00
maziggy 25a23eadd7 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-21 10:25:57 +02:00
maziggy a5fe5cb3d4 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-20 15:50:58 +02:00
phieb b414af6b1c feat(gcode-injection): per-VP opt-in auto-print injection toggle (#1516) (#1656) 2026-06-19 11:29:07 +02:00
maziggy 9f8bac63ff fix(makerworld): resolve API-key owner for cloud-token lookups (#1777)
The makerworld /status, /resolve, and /import handlers passed
  current_user directly into get_stored_token / _build_service.
  require_permission_if_auth_enabled returns None for API-keyed
  callers by design (core/auth.py:1414), so the lookup always
  missed even when the key's owner had a stored Bambu Cloud session.
  Result: a "requires a Bambu Cloud login" 400 on every API-keyed
  import, regardless of the owning account's actual cloud state.

  Wire resolve_api_key_cloud_owner (already used by the slice path
  in #1182 — slicer_presets.py:491 and library.py:3871) into the
  three makerworld routes that read the cloud token. The handler
  falls back to the API-key owner via cloud_token_user =
  current_user or api_key_cloud_owner, then passes that through.
  import_instance also propagates the resolved user to the
  owner_id arg on save_3mf_bytes_to_library, so the resulting
  LibraryFile.created_by_id reflects the key's owner instead of
  NULL.

  Fail-closed semantics preserved: resolve_api_key_cloud_owner
  already fences on api_key.can_access_cloud, so keys with only
  the per-route scope (can_read_status / can_manage_library) still
  take the existing "requires Bambu Cloud login" path — no auth
  widening.

  /recent-imports is unchanged — it only uses current_user as a
  permission gate (_ = current_user) and never touches the cloud
  token.
2026-06-19 08:05:20 +02:00
maziggy 52448a374e 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-18 12:07:56 +02:00
Ed 0f99b54d7e feat: Update printer card UI for structure and readability (#1661) 2026-06-18 11:46:07 +02:00
maziggy 9a432f0050 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-18 07:36:16 +02:00
Poltavtcev af5d24e289 feat(inventory): structured storage locations catalog (#1505) 2026-06-17 11:33:23 +02:00
maziggy 2940fbdcf7 feat(auth): admin-configurable session lifetime ceiling (#1706)
The 24h session cap from the M-2 audit finding was hard-coded, so the
  "Remember Me" checkbox could only control storage location, never
  duration. Add session_max_hours setting (default 24, max 720) honoured
  at all four token-issuance sites: plain login, 2FA TOTP/email, 2FA
  backup, OIDC.

  - backend/app/core/auth.py: SESSION_MAX_HOURS_HARD_CEILING + resolver
    that clamps to [1h, 720h] and falls back to 24h on missing/blank/
    unparseable. DB errors propagate — the login transaction must abort
    on a broken DB rather than silently extend or shrink the lifetime.
  - backend/app/api/routes/auth.py, mfa.py: all four sites read the
    resolved value instead of ACCESS_TOKEN_EXPIRE_MINUTES directly.
  - backend/app/schemas/settings.py, routes/settings.py: schema field
    with ge=1 le=720 + int coercion in _build_settings_response.
  - frontend/src/pages/SettingsPage.tsx: half-width card at top of
    Settings -> Users left column with 24h/7d/30d presets, custom input,
    and a yellow warning when value > 24h.
  - frontend/src/i18n/locales/*.ts: 8 new keys per locale, real
    translations in all 11 (en/de/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW).
  - backend/tests/integration/test_session_policy.py: 15 tests across
    resolver clamping, login JWT exp end-to-end, settings API round-trip.

  Already-issued tokens keep their original expiry; the new setting only
  affects future logins.
2026-06-16 12:00:27 +02:00
maziggy eb5154f61a feat(queue): tabbed page, batch grouping, multi-drag, Gantt timeline
Restructures the queue page around three tabs (Queue / History / Timeline)
  and adds first-class batch grouping plus a real time-based timeline.

  Queue tab
  - Layout toggle: Sort by Position (flat list) or Group by Printer (per-
    printer section cards with aggregate count / time / weight headers).
  - Batch grouping: pending items sharing a batch_id render as a single
    collapsible row with aggregate stats; children draggable within the
    batch only. Per-batch collapse state in localStorage.
  - Multi-drag: dragging any selected row moves all selected items as a
    contiguous block via DragOverlay (+N ghost).
  - Selection bar gains a Group as batch action when 2+ ungrouped items
    are selected. Ungroup lives on the batch parent row.

  History tab
  - Two-line rich rows: filament color swatch + weight + type, user
    attribution, inline error message on failed / skipped rows.
  - Responsive 1 / 2 / 3 column grid so a long history uses available
    width instead of stretching one row per line.
  - Batch siblings group into a collapsible parent with status-rollup
    chips (3 OK / 1 failed / etc).
  - Thumbnail hover preview shows the full image at 192x192 next to the
    small thumb.

  Timeline tab
  - Replaces the hourly-list view with a Gantt swimlane: one row per
    printer (plus per target_model and unassigned), horizontal hour
    axis, jobs as bars positioned by start time and sized by duration.
  - Live NOW marker.
  - Only committed schedules are rendered: currently printing items,
    pending items with scheduled_time, and pending ASAP behind an active
    print. Staged (manual_start), waiting (waiting_reason), and ASAP
    jobs on idle printers are filtered out.
  - 24h rolling window with 12h step controls.
  - Per-bar tooltip with start, end, progress, batch name.

  Backend
  - POST /queue/batches creates a batch, optionally assigning existing
    pending item_ids (manual grouping) or returning an empty batch the
    client can attach to subsequent /queue/ POSTs.
  - POST /queue/batches/{id}/ungroup clears batch_id from all members
    (skipping items the caller does not own) and deletes the batch row
    when no members remain.
  - POST /queue/ accepts an optional batch_id and validates that the
    batch exists, is active, and the caller may modify it. The existing
    quantity > 1 auto-batch path still fires when no batch_id is sent.

  PrintModal
  - When N plates from one source are queued in a single submission
    (model assignment or single printer), the modal pre-creates a batch
    and passes its id to each addToQueue call so multi-plate jobs land
    grouped automatically. Falls back to ungrouped items if the batch
    pre-create fails.
2026-06-16 10:45:30 +02:00
maziggy 428f3f2db1 Resolved conflicts 2026-06-14 10:52:41 +02:00
maziggy 2cf6f29503 fix(vp): Send All enqueues one item per plate; archive delete cascades to queue
VP queue-mode multi-plate Send All
  ==========================================

  BambuStudio / OrcaSlicer "Send All" of a multi-plate project uploads ONE
  3MF containing every plate (one FTP STOR, single filename) — slice_info.config
  inside the file lists N <plate> blocks with their own index metadata and
  their own Metadata/plate_N.gcode payload. Pre-#1733 the VP queue path
  called _extract_plate_id which returned only the FIRST plate index, and
  _add_to_print_queue built exactly one PrintQueueItem from it. Plates 2..N
  silently dropped on the floor. From the user's perspective: Send All of a
  3-plate project produced 1 queue item, indistinguishable from a regular
  single-plate Send, with no log line to explain the discrepancy.

  The wire was confirmed against the live H2D-1 Proxy VP: the same file
  ships whether the user clicked Send or Send All; the only intent signal
  is the count of <plate> blocks inside slice_info.config.

  Fix: replaced _extract_plate_id (-> int | None) with _extract_plate_ids
  (-> list[int]). The list contains every <plate> block's index in order;
  falls back to [1] when slice_info.config is missing / unparseable so the
  single-plate case is preserved. _add_to_print_queue now loops over the
  list and creates one PrintQueueItem per plate, with:

    - plate-specific position = MAX(position) + iteration_number, so the
      items inherit consecutive positions and the slicer's plate order
      becomes the queue execution order.
    - per-plate required_filament_types / filament_overrides via
      extract_filament_requirements(file_path, plate_id) — the plate-aware
      filter shipped with #1697 — so the scheduler's per-printer "Any X"
      matching dispatches each plate onto a printer with the right
      colours loaded for THAT plate, not for plate 1's filament set.
    - shared archive_id across all plates (one upload = one archive row).
    - the VP's auto_dispatch + manual_start posture inherited unchanged.

  Net behaviour: single-plate Send hits the loop once → exactly today's
  result (one queue item, plate_id from the slicer, one archive). Multi-
  plate Send All of a 3-plate file → 3 queue items, plate_id 1/2/3,
  consecutive positions, all referencing the same backing archive.

  Archive delete cascades to queue rows
  =============================================

  Previously the soft-delete path (the default the trash-can button uses)
  called _cancel_pending_queue_items which only flipped queue rows with
  status='pending' to status='cancelled' while leaving every other status
  alone AND leaving every row in the DB. The Send All multi-plate work
  above made this much more visible: deleting an archive backed by N
  queue items now had to clean up N rows, and what users saw instead was
  N "cancelled" rows lingering in the queue history.

  Backend:
    - Replaced _cancel_pending_queue_items with _delete_related_queue_items
      (db, archive_id) -> int. DELETEs every queue row where
      archive_id = X regardless of status. Matches what the hard-delete
      path already did via the ON DELETE CASCADE FK on
      print_queue.archive_id — both paths now produce the same end state.
    - Print history lives in PrintLogEntry (FK ON DELETE SET NULL) and is
      untouched; Quick Stats / accuracy bands are preserved across both
      delete paths.
    - 409 guard on archives.py::delete_archive when any related queue
      item is currently status='printing'. Both soft and hard delete are
      gated; deleting the archive while a print is live would strip the
      dispatcher's metadata trail (filament / plate / ams_mapping) out
      from under the running print.
    - New GET /archives/{id}/delete-impact endpoint returns
      {related_queue_items: N, currently_printing: M}. Cheap, single
      endpoint, deliberately NOT folded into the archive list response
      so the much larger list endpoint isn't forced to run the same
      query per row.

  Frontend:
    - ArchivesPage delete-confirm modal queries the new endpoint when the
      modal opens (useQuery with enabled: showDeleteConfirm) and renders
      an amber "N queue items linked to this archive will also be removed"
      line when total > 0 AND printing = 0, OR a red "Cannot delete —
      M queue items are currently printing" line when printing > 0
      (confirm button disabled in that case so the user can't bonk the
      409 on submit).
    - ConfirmModal gained an optional confirmDisabled?: boolean prop —
      isLoading was the only disable knob before; this adds the external-
      precondition path.
    - 2 new i18n keys (deleteQueueItemsWarning, deleteBlockedByPrinting)
      translated across all 11 locales per feedback_translate_dont_fallback —
      no English fallbacks.

  No DB migration — the CASCADE FK was already in place; only the helper's
  semantics changed.
2026-06-13 15:58:57 +02:00