Commit Graph
367 Commits
Author SHA1 Message Date
maziggy 5cf429f696 feat(labels): scannable QR on 203 dpi thermal printers + monochrome mode (#1870)
The 40x30 mm box label rendered its QR too densely for low-res thermal
    printers — the modules bled together and wouldn't scan. Two causes: the QR
    was 20% of inner width (~7.5 mm on the narrowest template, half of the
    others) and used ERROR_CORRECT_M. Fix adaptively so all templates benefit:
    give the roomy-layout QR a 12 mm minimum size (box_40x30 -> 12 mm, ~3.5
    dots/module at 203 dpi) and switch label QRs to ERROR_CORRECT_L (same
    payload, chunkier modules; a label needs no M-level recovery). Keep the
    quiet-zone border at 2 — the size+L gains suffice without risking scans.

    Also add a Monochrome (black & white printer) option to the label dialog:
    drops the colour swatch (a useless grey block on B&W) and widens the text;
    the hex-code line still carries the colour. Threaded through the renderer,
    route, API client, and modal, with translations in all 11 locales.
2026-07-07 11:07:08 +02:00
maziggy c5b02d9473 fix(auth): let API keys manage projects via new can_manage_projects scope (#1893)
PROJECTS_CREATE/UPDATE/DELETE were in _APIKEY_DENIED_PERMISSIONS with no
    entry in _APIKEY_SCOPE_BY_PERMISSION, so every project mutation returned a
    generic 403 for any API key regardless of granted permissions -- the same
    regression class as archives (#1888) and library (#1832).

    Add a per-key can_manage_projects scope. Project routes gate on plain
    PROJECTS_* (no OWN/ALL split), so all three CRUD permissions map to the one
    scope; membership edits (add-archives) gate on PROJECTS_UPDATE and are
    covered. PROJECTS_READ is unchanged (already under can_read_status).

    Column defaults TRUE for new keys; existing rows backfill to FALSE so the
    upgrade never silently widens scope. Migration is BOOLEAN (SQLite + Postgres
    safe), verified on fresh SQLite and Postgres 17. Bundled SpoolBuddy kiosk key
    set to False. Settings API-key UI gets a Manage Projects toggle + Projects
    badge; 11-locale i18n. RBAC scope matrix + drift guards extended.
2026-07-07 11:03:46 +02:00
maziggy 99d06f3cd1 fix(auth): allow API keys to delete/edit archives via new can_manage_archives scope (#1888)
DELETE /api/v1/archives/{id} rejected every API key with 403
    "API keys cannot be used for administrative operations", regardless of
    the print's owner or the key's scopes. ARCHIVES_DELETE_ALL/_OWN (and the
    create/update variants) were on the denylist and absent from the scope
    allowlist, so require_ownership_permission fell through to the generic
    admin-denied 403 — the whole archive-management surface was unreachable
    for API keys. Same regression class as the #1832 library/maintenance
    carve-outs.

    Add a can_manage_archives per-key scope: ARCHIVES_CREATE, ARCHIVES_
    UPDATE_OWN/_ALL and ARCHIVES_DELETE_OWN/_ALL move from the denylist to
    the allowlist under it (OWN and ALL fold into the same scope, matching
    can_manage_library). ARCHIVES_PURGE stays admin-only — it drops the
    print's Quick Stats contribution, mirroring LIBRARY_PURGE. Column
    defaults TRUE for UI-created keys; existing rows backfill to FALSE so the
    upgrade never silently widens scope. Bundled SpoolBuddy kiosk key stays
    minimally scoped (False). Migration is dialect-agnostic and verified on
    fresh SQLite and Postgres 17.

    Adds the Settings API-key toggle + badge (11-locale i18n) and extends the
    RBAC scope matrix to cover all five archive-management permissions.
2026-07-07 11:01:57 +02:00
maziggy b8b5aaa977 feat(api-keys): can_manage_maintenance scope for HA-style automations (#1832 follow-up)
Carve MAINTENANCE_CREATE/UPDATE/DELETE out of the admin denylist so
    HA automations can log "cleaned nozzle" / reset a counter via API key
    without granting broader printer control. Follows the same shape as
    can_manage_library and can_manage_inventory: new column, allowlist
    entry, UI checkbox, wiki row, RBAC test coverage.

    Distinct backfill: these perms were EXPLICITLY denied for every API
    key before this change (no existing integration relies on them), so
    existing rows migrate to FALSE — no silent scope widening on upgrade.
    New keys default to TRUE, matching the safe-on-by-default pattern.
    Bundled SpoolBuddy kiosk key gets False explicitly (kiosk doesn't need it).
2026-07-07 10:58:09 +02:00
maziggy 2fe6982981 fix(hms): wrong-plate Ignore actually ignores + buttons read as buttons + ack-detection survives transient re-pause (#1869)
The HMS error modal had three compounding bugs that surfaced when a
    user forced a wrong-plate HMS (0500_8051) and tried to dispatch the
    per-fault actions.

    (1) IGNORE_RESUME did not ignore. Bambuddy redirected the action on
    state=PAUSE to a plain `resume` command, citing a #1830 verdict that
    BambuStudio's "err-bearing shape" was firmware-silently-rejected.
    BambuStudio source disagrees: DeviceErrorDialog.cpp:600 dispatches
    IGNORE_RESUME via command_hms_ignore, whose wire shape is
    {command:"ignore", err:"<decimal>", param:"reserve", job_id:...}.
    That's a distinct command from `resume` — the firmware suppresses
    the next re-check AND auto-resumes in one operation. Plain resume
    means "re-check normally", which is exactly why the wrong-plate
    detection re-fired 1-2 s after the user clicked Ignore. The #1830
    "err-bearing shape rejected" test almost certainly sent the err as
    a hex shortcode; BambuStudio passes std::to_string(int m_error_code)
    i.e. the DECIMAL form, which is what the firmware matches against.

    (2) Action buttons read as inert badges. The button className used
    `hover:${buttonHoverColor}` — a template-literal interpolation
    Tailwind's JIT scanner can't see as a literal string, so the
    per-severity hover utility never reached the compiled CSS. Same
    bg/text color as the severity badge above and no border made it
    read as another label. No disabled state and no spinner during the
    2.5 s ack wait left clicks sitting silently inert.

    (3) Ack-detection 502'd on legitimate ack. The route compared
    (gcode_state, hms_errors-len) before vs after publish; wrong-plate
    re-pause round-tripped both fields to their pre-publish values
    inside the 2.5 s window → false 502 even though the firmware fully
    ack'd. PROBLEM_SOLVED_RESUME working but IGNORE_RESUME 502'ing on
    the same fault was the same race resolving differently.

    Fixes:

    bambu_mqtt.py — new hms_ignore_command() publishes the BambuStudio
    shape; existing hms_ignore(persistent) renamed to hms_idle_ignore
    (unchanged shape, used by NO_REMINDER_NEXT_TIME per
    DeviceErrorDialog.cpp:588). Dispatch routes IGNORE_RESUME,
    IGNORE_NO_REMINDER_NEXT_TIME, and DONT_REMIND_NEXT_TIME to
    hms_ignore_command (BambuStudio routes all three to the same
    command_hms_ignore — the "don't remind" half is the firmware's
    job). NO_REMINDER_NEXT_TIME stays on hms_idle_ignore type=0. Hex →
    decimal err conversion at the helper layer with a defensive
    fallback. job_id=None → empty string (matches BambuStudio's
    std::string default).

    HMSErrorModal.tsx — getSeverityInfo loses the dead buttonHoverColor
    field. Action button uses static
    `bg-white/10 hover:bg-white/20 active:bg-white/30 text-white
    border border-white/20`, wires
    `disabled={!hasPermission||mutation.isPending}`, and renders
    `<Loader2/>` only on the button whose (action,print_error) matches
    mutation.variables.

    printers.py — ack-detection probes `client._last_message_time`
    (bumped on every MQTT push regardless of payload) rather than
    diffing state fields. The pushall that follows every command
    guarantees a fresh push lands inside the 2.5 s window on any
    healthy printer; only firmware-silent-drop leaves the timestamp
    untouched, which is the 502 path #1830 wanted.
2026-07-07 10:52:19 +02:00
maziggy 6507fbcc40 fix(slicer): surface real CLI rejections + hard-skip mismatched filaments in auto-pick (#1851)
Two compounding bugs let an H2C-bound filament land in slot 1 of an A1
    slice silently. (1) `_slicer_rejection_message` discarded the actual CLI
    diagnostic - `filament preset Generic PLA @BBL H2C (slot 1) is not
    compatible with printer Bambu Lab A1 0.4 nozzle.` - when the sidecar's
    headline error_string was Bambu Studio's catch-all
    `The input preset file is invalid and can not be parsed.` placeholder.
    The real reason was in the stdout `[error] run NNNN:` line, trimmed off
    before reaching the SliceJob's error_detail. (2) `pickFilamentForSlot`
    used a soft `-100` mismatch penalty rather than a hard skip, leaving
    the "never auto-fill an incompatible preset while a compatible one
    exists" contract implicit. The unused-slot substitution in
    `substitute_unused_plate_filaments` then propagated whatever slot 1
    held across every unused slot - one bad pick poisoned the array.

    (1) Mine `[error] <msg>` (with or without `run NNNN:`) from the full
    pre-trim response; substitute the placeholder, keep meaningful
    headlines. (2) Partition candidates into compatible/unknown vs
    mismatch; prefer compatible whenever the bucket is non-empty, fall
    back to mismatch only on graceful-degrade. Picker helpers moved out
    of `SliceModal.tsx` into `utils/slicePresetPicker.ts` so the modal
    file stays component-only (react-refresh lint).
2026-07-07 10:51:51 +02:00
maziggy b26b68c236 fix(permissions): self-heal Administrators to ALL_PERMISSIONS on upgrade + Pipelines runs dashboard polish
Administrators system group sync
    - Fresh installs already bootstrap with ALL_PERMISSIONS, so they always have
      every permission. Upgrades previously only got what one-off backfill blocks
      in seed_default_groups() explicitly listed (library:purge, archives:purge,
      the OWN/ALL read-flag block, orca_cloud:auth, pipelines:*). Any Permission
      enum member added without a matching block silently stayed missing on
      existing admin rows. The most recent gap was printer_sensor_history:read
      (Sensor History charts returned 403 for upgraded admins).
    - seed_default_groups() now syncs Administrators to ALL_PERMISSIONS on every
      startup: append every Permission value that isn't already on the row.
      Additive only -- hand-added custom permissions are preserved.
    - The pure-admin one-off backfills (library:purge / archives:purge block,
      the OWN/ALL + orca_cloud:auth + legacy-read-flag block, the Administrators
      branch of the pipeline backfill) are retired since the sync subsumes
      them. Non-admin backfills (Operators / Viewers OWN-tier reads, Operators
      orca_cloud:auth, pipelines for non-admin groups, makerworld:*, clear_plate
      cross-group adders) are untouched.
    - Tests: test_administrators_printer_sensor_history_read_backfilled
      (regression for the reported gap),
      test_administrators_sync_covers_every_current_permission (generic
      invariant -- any future new permission lands on admin without needing
      a one-off test), test_administrators_sync_is_additive_only (custom
      permissions preserved). 12/12 backfill-migration + 102/102 broader
      permission tests green; ruff clean.

    Pipelines runs dashboard
    - PipelineRunsPage.tsx: the Pipeline / Status / Target filter row's three
      native <select> elements are replaced with a bambu-themed FilterDropdown
      (button trigger, floating menu, optgroup-style headers for the Target
      picker, hover + selected states with a check mark, closes on outside
      click and Escape). Same value/onChange contract -- visual only.
    - SlicerPipelinesPanel.tsx: wrap list?.pipelines ?? [] in useMemo so the
      reference is stable when the data is stable. Fixes the
      react-hooks/exhaustive-deps warning where the inline fallback returned
      a fresh empty array every render, invalidating both downstream useMemo
      caches (target-options + filtered-pipelines list).
2026-07-07 10:49:40 +02:00
maziggy 917bfd7666 feat(labels): scannable QR on 203 dpi thermal printers + monochrome mode (#1870)
The 40x30 mm box label rendered its QR too densely for low-res thermal
printers — the modules bled together and wouldn't scan. Two causes: the QR
was 20% of inner width (~7.5 mm on the narrowest template, half of the
others) and used ERROR_CORRECT_M. Fix adaptively so all templates benefit:
give the roomy-layout QR a 12 mm minimum size (box_40x30 -> 12 mm, ~3.5
dots/module at 203 dpi) and switch label QRs to ERROR_CORRECT_L (same
payload, chunkier modules; a label needs no M-level recovery). Keep the
quiet-zone border at 2 — the size+L gains suffice without risking scans.

Also add a Monochrome (black & white printer) option to the label dialog:
drops the colour swatch (a useless grey block on B&W) and widens the text;
the hex-code line still carries the colour. Threaded through the renderer,
route, API client, and modal, with translations in all 11 locales.
2026-07-07 10:31:19 +02:00
maziggy 168d9d8f8e fix(auth): let API keys manage projects via new can_manage_projects scope (#1893)
PROJECTS_CREATE/UPDATE/DELETE were in _APIKEY_DENIED_PERMISSIONS with no
entry in _APIKEY_SCOPE_BY_PERMISSION, so every project mutation returned a
generic 403 for any API key regardless of granted permissions -- the same
regression class as archives (#1888) and library (#1832).

Add a per-key can_manage_projects scope. Project routes gate on plain
PROJECTS_* (no OWN/ALL split), so all three CRUD permissions map to the one
scope; membership edits (add-archives) gate on PROJECTS_UPDATE and are
covered. PROJECTS_READ is unchanged (already under can_read_status).

Column defaults TRUE for new keys; existing rows backfill to FALSE so the
upgrade never silently widens scope. Migration is BOOLEAN (SQLite + Postgres
safe), verified on fresh SQLite and Postgres 17. Bundled SpoolBuddy kiosk key
set to False. Settings API-key UI gets a Manage Projects toggle + Projects
badge; 11-locale i18n. RBAC scope matrix + drift guards extended.
2026-07-05 09:58:16 +02:00
maziggy 6358e9544e fix(auth): allow API keys to delete/edit archives via new can_manage_archives scope (#1888)
DELETE /api/v1/archives/{id} rejected every API key with 403
"API keys cannot be used for administrative operations", regardless of
the print's owner or the key's scopes. ARCHIVES_DELETE_ALL/_OWN (and the
create/update variants) were on the denylist and absent from the scope
allowlist, so require_ownership_permission fell through to the generic
admin-denied 403 — the whole archive-management surface was unreachable
for API keys. Same regression class as the #1832 library/maintenance
carve-outs.

Add a can_manage_archives per-key scope: ARCHIVES_CREATE, ARCHIVES_
UPDATE_OWN/_ALL and ARCHIVES_DELETE_OWN/_ALL move from the denylist to
the allowlist under it (OWN and ALL fold into the same scope, matching
can_manage_library). ARCHIVES_PURGE stays admin-only — it drops the
print's Quick Stats contribution, mirroring LIBRARY_PURGE. Column
defaults TRUE for UI-created keys; existing rows backfill to FALSE so the
upgrade never silently widens scope. Bundled SpoolBuddy kiosk key stays
minimally scoped (False). Migration is dialect-agnostic and verified on
fresh SQLite and Postgres 17.

Adds the Settings API-key toggle + badge (11-locale i18n) and extends the
RBAC scope matrix to cover all five archive-management permissions.
2026-07-03 08:01:54 +02:00
maziggy 006c3113a0 feat(api-keys): can_manage_maintenance scope for HA-style automations (#1832 follow-up)
Carve MAINTENANCE_CREATE/UPDATE/DELETE out of the admin denylist so
HA automations can log "cleaned nozzle" / reset a counter via API key
without granting broader printer control. Follows the same shape as
can_manage_library and can_manage_inventory: new column, allowlist
entry, UI checkbox, wiki row, RBAC test coverage.

Distinct backfill: these perms were EXPLICITLY denied for every API
key before this change (no existing integration relies on them), so
existing rows migrate to FALSE — no silent scope widening on upgrade.
New keys default to TRUE, matching the safe-on-by-default pattern.
Bundled SpoolBuddy kiosk key gets False explicitly (kiosk doesn't need it).
2026-07-01 09:21:09 +02:00
maziggy a45d32efd0 fix(hms): wrong-plate Ignore actually ignores + buttons read as buttons + ack-detection survives transient re-pause (#1869)
The HMS error modal had three compounding bugs that surfaced when a
user forced a wrong-plate HMS (0500_8051) and tried to dispatch the
per-fault actions.

(1) IGNORE_RESUME did not ignore. Bambuddy redirected the action on
state=PAUSE to a plain `resume` command, citing a #1830 verdict that
BambuStudio's "err-bearing shape" was firmware-silently-rejected.
BambuStudio source disagrees: DeviceErrorDialog.cpp:600 dispatches
IGNORE_RESUME via command_hms_ignore, whose wire shape is
{command:"ignore", err:"<decimal>", param:"reserve", job_id:...}.
That's a distinct command from `resume` — the firmware suppresses
the next re-check AND auto-resumes in one operation. Plain resume
means "re-check normally", which is exactly why the wrong-plate
detection re-fired 1-2 s after the user clicked Ignore. The #1830
"err-bearing shape rejected" test almost certainly sent the err as
a hex shortcode; BambuStudio passes std::to_string(int m_error_code)
i.e. the DECIMAL form, which is what the firmware matches against.

(2) Action buttons read as inert badges. The button className used
`hover:${buttonHoverColor}` — a template-literal interpolation
Tailwind's JIT scanner can't see as a literal string, so the
per-severity hover utility never reached the compiled CSS. Same
bg/text color as the severity badge above and no border made it
read as another label. No disabled state and no spinner during the
2.5 s ack wait left clicks sitting silently inert.

(3) Ack-detection 502'd on legitimate ack. The route compared
(gcode_state, hms_errors-len) before vs after publish; wrong-plate
re-pause round-tripped both fields to their pre-publish values
inside the 2.5 s window → false 502 even though the firmware fully
ack'd. PROBLEM_SOLVED_RESUME working but IGNORE_RESUME 502'ing on
the same fault was the same race resolving differently.

Fixes:

bambu_mqtt.py — new hms_ignore_command() publishes the BambuStudio
shape; existing hms_ignore(persistent) renamed to hms_idle_ignore
(unchanged shape, used by NO_REMINDER_NEXT_TIME per
DeviceErrorDialog.cpp:588). Dispatch routes IGNORE_RESUME,
IGNORE_NO_REMINDER_NEXT_TIME, and DONT_REMIND_NEXT_TIME to
hms_ignore_command (BambuStudio routes all three to the same
command_hms_ignore — the "don't remind" half is the firmware's
job). NO_REMINDER_NEXT_TIME stays on hms_idle_ignore type=0. Hex →
decimal err conversion at the helper layer with a defensive
fallback. job_id=None → empty string (matches BambuStudio's
std::string default).

HMSErrorModal.tsx — getSeverityInfo loses the dead buttonHoverColor
field. Action button uses static
`bg-white/10 hover:bg-white/20 active:bg-white/30 text-white
border border-white/20`, wires
`disabled={!hasPermission||mutation.isPending}`, and renders
`<Loader2/>` only on the button whose (action,print_error) matches
mutation.variables.

printers.py — ack-detection probes `client._last_message_time`
(bumped on every MQTT push regardless of payload) rather than
diffing state fields. The pushall that follows every command
guarantees a fresh push lands inside the 2.5 s window on any
healthy printer; only firmware-silent-drop leaves the timestamp
untouched, which is the 502 path #1830 wanted.
2026-06-29 10:59:29 +02:00
maziggy 425a3ac404 fix(slicer): surface real CLI rejections + hard-skip mismatched filaments in auto-pick (#1851)
Two compounding bugs let an H2C-bound filament land in slot 1 of an A1
slice silently. (1) `_slicer_rejection_message` discarded the actual CLI
diagnostic - `filament preset Generic PLA @BBL H2C (slot 1) is not
compatible with printer Bambu Lab A1 0.4 nozzle.` - when the sidecar's
headline error_string was Bambu Studio's catch-all
`The input preset file is invalid and can not be parsed.` placeholder.
The real reason was in the stdout `[error] run NNNN:` line, trimmed off
before reaching the SliceJob's error_detail. (2) `pickFilamentForSlot`
used a soft `-100` mismatch penalty rather than a hard skip, leaving
the "never auto-fill an incompatible preset while a compatible one
exists" contract implicit. The unused-slot substitution in
`substitute_unused_plate_filaments` then propagated whatever slot 1
held across every unused slot - one bad pick poisoned the array.

(1) Mine `[error] <msg>` (with or without `run NNNN:`) from the full
pre-trim response; substitute the placeholder, keep meaningful
headlines. (2) Partition candidates into compatible/unknown vs
mismatch; prefer compatible whenever the bucket is non-empty, fall
back to mismatch only on graceful-degrade. Picker helpers moved out
of `SliceModal.tsx` into `utils/slicePresetPicker.ts` so the modal
file stays component-only (react-refresh lint).
2026-06-29 08:23:29 +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 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 b23cb69a66 fix(permissions): self-heal Administrators to ALL_PERMISSIONS on upgrade + Pipelines runs dashboard polish
Administrators system group sync
- Fresh installs already bootstrap with ALL_PERMISSIONS, so they always have
  every permission. Upgrades previously only got what one-off backfill blocks
  in seed_default_groups() explicitly listed (library:purge, archives:purge,
  the OWN/ALL read-flag block, orca_cloud:auth, pipelines:*). Any Permission
  enum member added without a matching block silently stayed missing on
  existing admin rows. The most recent gap was printer_sensor_history:read
  (Sensor History charts returned 403 for upgraded admins).
- seed_default_groups() now syncs Administrators to ALL_PERMISSIONS on every
  startup: append every Permission value that isn't already on the row.
  Additive only -- hand-added custom permissions are preserved.
- The pure-admin one-off backfills (library:purge / archives:purge block,
  the OWN/ALL + orca_cloud:auth + legacy-read-flag block, the Administrators
  branch of the pipeline backfill) are retired since the sync subsumes
  them. Non-admin backfills (Operators / Viewers OWN-tier reads, Operators
  orca_cloud:auth, pipelines for non-admin groups, makerworld:*, clear_plate
  cross-group adders) are untouched.
- Tests: test_administrators_printer_sensor_history_read_backfilled
  (regression for the reported gap),
  test_administrators_sync_covers_every_current_permission (generic
  invariant -- any future new permission lands on admin without needing
  a one-off test), test_administrators_sync_is_additive_only (custom
  permissions preserved). 12/12 backfill-migration + 102/102 broader
  permission tests green; ruff clean.

Pipelines runs dashboard
- PipelineRunsPage.tsx: the Pipeline / Status / Target filter row's three
  native <select> elements are replaced with a bambu-themed FilterDropdown
  (button trigger, floating menu, optgroup-style headers for the Target
  picker, hover + selected states with a check mark, closes on outside
  click and Escape). Same value/onChange contract -- visual only.
- SlicerPipelinesPanel.tsx: wrap list?.pipelines ?? [] in useMemo so the
  reference is stable when the data is stable. Fixes the
  react-hooks/exhaustive-deps warning where the inline fallback returned
  a fresh empty array every render, invalidating both downstream useMemo
  caches (target-options + filtered-pipelines list).
2026-06-28 11:18:18 +02:00
maziggy 3ef197e4e0 feat(slicer): Pipelines — multi-copy + class targeting + fanout + runs dashboard + retry-failed + WS updates (#1425 PR C — completes the v3 design)
PR A/B turned the slice modal's preset bundle into a one-click dispatch
with a pinned target printer. PR C closes the original issue: operators
type in a number of copies, Bambuddy slices once and distributes prints
across a fleet per the pipeline's chosen fanout strategy. A new dashboard
surfaces every run with filters, expandable per-copy status, cancel,
and retry-failed-copies. WS pushes keep everything live.

Backend
- copies field on POST /run, capped by new pipeline_max_copies setting
  (default 50, hard cap 1000). PipelineRun.parent_run_id chains retries.
- SlicerPipelineUpdate accepts target_kind (specific_printer /
  printer_class), target_model_class, fanout_strategy.
- Eligibility matcher branches: class-targeting enumerates matching
  Printer rows, runs per-printer checks via a status_lookup closure,
  returns printer_reports[]. New issue kinds: no_class_matches,
  class_not_set.
- _pick_assignments distributes copies per strategy:
  - max_parallel: target_model set, printer_id None — scheduler picks
  - round_robin: copy i → eligible[i % N], fixed printer_id
  - fill_one_first: all copies pinned to eligible[0]
  All three reuse the slice-once path through slice_dispatch.enqueue.
- New routes:
  - GET /pipeline-runs (paginated, filterable by pipeline + status)
  - POST /pipeline-runs/{id}/retry-failed (creates child run with
    copies = failed+cancelled count, parent_run_id set)
  - Cancel cascades to all N queue entries (only pending/queued)
- _roll_up_run_status computes run-level status from per-job statuses;
  introduces partial_failure for "some completed, some failed".
- ws_manager.broadcast_to_user emits pipeline_run_updated on every
  state transition with the full materialised response.

Frontend
- Pipeline editor: target_kind radio + class picker (filtered to
  installed models) + fanout-strategy radio. Read-only row shows
  "X1C · Round robin" for class pipelines.
- RunWithPipelineModal: copies number input bounded by
  settings.pipeline_max_copies. Accepts class-targeted pipelines.
- Settings → Workflow → Queue & Dispatch: new "Slicer Pipeline limits"
  card with the max-copies input.
- New /pipelines/runs dashboard page (sidebar entry, gated on
  pipelines:read). Two-filter dropdown, 25-per-page pagination, per-row
  expandable to job list, Cancel + Retry-failed buttons.
- useWebSocket case for pipeline_run_updated invalidates both
  pipeline-runs-all and pipeline-runs/{id} query keys.
2026-06-27 16:52:05 +02:00
maziggy 4bbf0f031e feat(slicer): Pipelines — archive entry point + slicer progress toast (#1425 PR B follow-up)
Two real gaps from the PR B drop:

1. Run-with-pipeline only existed in the file manager. Operators who keep
   working files in archives had to copy them to the library to use a
   pipeline.

2. Triggering a slice via a pipeline produced a silent multi-second-to-
   minute wait. The manual SliceModal flow shows the sticky
   "Slicing X - Generating G-code 75%" persistent toast; the pipeline
   path went through asyncio.create_task directly and never registered
   with SliceJobTracker.

Archive entry point
- POST /slicer-pipelines/{id}/check-eligibility and /run accept
  source_archive_id as an alternative to source_library_file_id (XOR,
  enforced by Pydantic validator).
- PipelineRun.source_archive_id is a new nullable FK column with the
  ALTER TABLE migration in run_migrations (idempotent via _safe_execute,
  works on SQLite + Postgres).
- _resolve_source branches: archive path reads source_3mf_path with
  fallback to file_path, mirroring routes/archives.py.
- ArchiveCard's context menu picks up a "Run with pipeline" item next to
  Slice (only on source archives), gated on useSlicerApi + pipelines:run.
  Slice (only on source archives), gated on useSlicerApi + pipelines:run.
- Path-safety: SEC-PATH-OK markers added at both LibraryFile.file_path
  and archive.source_3mf_path join sites, citing the upload-time
  validators.

Progress toast
- Pipeline orchestration is now the `run` callable of a
  slice_dispatch.enqueue call — the same dispatcher SliceModal uses —
  instead of a bare asyncio.create_task. The SliceJob lifecycle drives
  the existing progress toast end to end with no separate notification
  surface for pipeline runs.
- PipelineRun.slice_job_id is set before the 202 returns.
- RunWithPipelineModal calls useSliceJobTracker().trackJob() from
  runMutation.onSuccess.
- RunWithPipelineModal source prop is now {kind, id, filename}
  mirroring SliceModal.SliceSource; api.checkPipelineEligibility +
  api.runPipeline take a discriminated-union source argument.
2026-06-27 15:01:14 +02:00
maziggy d6bdb7e200 feat(slicer): Slicer Pipelines — save & reuse a preset bundle in one click (#1425 PR A)
The SliceModal forces the user to pick four slots every time (printer /
process / filament(s) / bed type). For fleet production that's tedious
and error-prone. Pipelines let an operator save a named bundle and apply
it with one click on the next file.

PR A is bundle-and-management only. PR B adds single-target dispatch,
PR C adds multi-copy batch with capability-matched fanout. Future-PR
columns (target_kind / target_printer_id / target_model_class /
fanout_strategy) ship in this migration so PR B+ is code-only, not a
schema bump.

Backend
- New model SlicerPipeline + slicer_pipelines table; soft-delete via
  is_deleted so PR B+ run history can still resolve metadata.
- Pydantic schemas reuse the existing PresetRef shape from
  schemas/slicer.py.
- CRUD routes at /api/v1/slicer-pipelines/ — list (newest first by id
  DESC), create (201), get-by-id, partial PUT, soft-delete (204).
- Three new permissions: PIPELINES_READ / PIPELINES_WRITE / PIPELINES_RUN.
  Administrators + Operators get all three; Viewers get READ.
  Backfill in seed_default_groups() so existing installs upgrade
  cleanly. All three denied to API keys for now.

Frontend
- Settings → Workflow splits into two horizontal sub-tabs mirroring
  the Authentication tab pattern: "Queue & Dispatch" (existing
  Workflow content) and "Pipelines" (new). URL deep-link via
  ?tab=queue&sub=pipelines.
- SlicerPipelinesPanel — list, inline rename, delete, stale-preset
  warning when a referenced preset no longer resolves.
- SliceModal gets "Apply pipeline ▾" + "Save as pipeline". Apply
  fills all four slot states; the filament list right-pads from
  current state so a pipeline with fewer entries than the current
  source's slot count keeps the existing tail.
2026-06-27 13:56:18 +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