Commit Graph
1463 Commits
Author SHA1 Message Date
maziggy 5a244661bc feat(scheduler): preheat & heat-soak before queued prints with per-filament chamber targets + airduct flap control (#1468)
New scheduler stage that heats the bed (and the chamber, on supported
    printers) and holds at temperature before each queued print starts —
    the heat-soak engineering filaments need for adhesion and warp
    control. Bambuddy waits between FTP upload and start_print, so the
    soak runs while the printer is otherwise idle. M191 is silently
    ignored by Bambu firmware, so doing this at the orchestration layer
    is the only place it works.

    Resolution order at dispatch:

    1. PrintQueueItem.preheat_override ∈ {inherit, on, off}.
       'off' skips entirely; 'inherit' falls back to the global
       preheat_enabled toggle; 'on' forces the stage even when the
       global is off.

    2. chamber_target = item.preheat_chamber_target_override
                     ?? max(filament_map[normalize(t.tray_type)] for loaded slots)
                     ?? 0.
       Mixed PA+PLA picks PA's 50 (max-across-slots — PA's chamber
       requirement is binding, PLA doesn't suffer being warm). PLA-only
       derives 0 and skips the chamber phase automatically.

    3. Three hardware tiers for chamber heat:
       - Active chamber heater (H2C/H2D/H2D Pro/H2S/X2D/X1E) → M141 +
         chamber-sensor wait
       - Chamber sensor only (X1C/P2S) → no M141, passive bed-radiation
         wait with hard max-wait cap
       - No chamber sensor (P1S/P1P/A1/A1 Mini) → bed + soak timer only

    4. Airduct flap (H2C/H2D/H2D Pro/H2S/X2D/P2S) auto-switches to
       match the chamber target — heating mode for engineering
       filaments, cooling mode for PLA. Bambu firmware does NOT
       auto-switch the flap with M141, so without this an ABS print
       on a previously-cooling flap fights the open exhaust, and a
       PLA print on a previously-hot flap recirculates ABS heat.
       Idempotent: only fires set_airduct_mode when current ≠ desired.

    Settings → Workflow → Queue & Dispatch → Preheat & Heat Soak card:
    master enable toggle (default off — disabled installs see no change),
    per-filament chamber-target editor (replaces a single global int that
    shipped in the first cut and couldn't serve PA + PLA in the same
    config), preheat_max_wait_seconds, preheat_soak_seconds. The Print
    Options panel in PrintModal gets a Preheat sub-section with the
    tri-state Inherit/On/Off control and an optional chamber-target
    override input.

    DB migration: PrintQueueItem gains preheat_override VARCHAR(10)
    DEFAULT 'inherit' and preheat_chamber_target_override INTEGER NULL.
    Idempotent via _safe_execute. Existing rows behave exactly as before
    the migration.

    Best-effort throughout: printer drops, refused M141 or set_airduct,
    missing bed temp, lost MQTT state mid-wait all log and return cleanly.
    Normal upload + start path runs after this returns regardless.
2026-07-07 10:52:50 +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 a57b752e3a fix(notifications): defer first-layer photo until printer is actually printing (#1837)
P1S and other Bambu printers tick layer_num during the pre-print calibration
    sequence (homing -> auto bed leveling -> bed-surface scan -> nozzle clean ->
    purge / wipe), so a bare `2 <= layer_num <= 5` gate fires the first-layer
    notification minutes before the first real extrusion. The attached photo
    shows a lowered bed, parked toolhead, and a clean plate -- exactly the
    state during PREPARE, not after layer 1.

    The reporter's log timeline made it explicit:
    - 13:54:27  PRINT START detected
    - 14:10:13  [SNAPSHOT] Capturing fresh frame  (notification fires here)
    - 14:44:28  gcode_state: RUNNING (debug log, only visible because they
                enabled debug logging mid-print)

    So the notification went out ~30 minutes before the print actually started.

    Fix in main.py:6043 -- the on_layer_change first-layer block now requires
    both:

      - state.state == "RUNNING" (gcode_state is RUNNING, not PREPARE)
      - state.mc_print_sub_stage in (None, 0)
        (0 = "Printing" in the canonical STAGE_NAMES at bambu_mqtt.py:376;
        None preserved as a no-opinion fall-through for any firmware that
        doesn't push the sub-stage so unknown-firmware installs keep
        their existing behaviour)

    _first_layer_notified is only set after the gate passes, so calibration
    ticks are non-consuming -- the next on_layer_change edge fires the
    notification once the printer is actually printing.

    The trigger window widens from [2, 5] to [2, 10] so that if calibration
    consumed several layer_num slots before RUNNING, the deferred edge
    still falls inside. The RUNNING + sub-stage gate ensures we don't fire
    on a stale layer count.
2026-07-07 10:50:15 +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 61a7f2e4ac feat(scheduler): preheat & heat-soak before queued prints with per-filament chamber targets + airduct flap control (#1468)
New scheduler stage that heats the bed (and the chamber, on supported
printers) and holds at temperature before each queued print starts —
the heat-soak engineering filaments need for adhesion and warp
control. Bambuddy waits between FTP upload and start_print, so the
soak runs while the printer is otherwise idle. M191 is silently
ignored by Bambu firmware, so doing this at the orchestration layer
is the only place it works.

Resolution order at dispatch:

1. PrintQueueItem.preheat_override ∈ {inherit, on, off}.
   'off' skips entirely; 'inherit' falls back to the global
   preheat_enabled toggle; 'on' forces the stage even when the
   global is off.

2. chamber_target = item.preheat_chamber_target_override
                 ?? max(filament_map[normalize(t.tray_type)] for loaded slots)
                 ?? 0.
   Mixed PA+PLA picks PA's 50 (max-across-slots — PA's chamber
   requirement is binding, PLA doesn't suffer being warm). PLA-only
   derives 0 and skips the chamber phase automatically.

3. Three hardware tiers for chamber heat:
   - Active chamber heater (H2C/H2D/H2D Pro/H2S/X2D/X1E) → M141 +
     chamber-sensor wait
   - Chamber sensor only (X1C/P2S) → no M141, passive bed-radiation
     wait with hard max-wait cap
   - No chamber sensor (P1S/P1P/A1/A1 Mini) → bed + soak timer only

4. Airduct flap (H2C/H2D/H2D Pro/H2S/X2D/P2S) auto-switches to
   match the chamber target — heating mode for engineering
   filaments, cooling mode for PLA. Bambu firmware does NOT
   auto-switch the flap with M141, so without this an ABS print
   on a previously-cooling flap fights the open exhaust, and a
   PLA print on a previously-hot flap recirculates ABS heat.
   Idempotent: only fires set_airduct_mode when current ≠ desired.

Settings → Workflow → Queue & Dispatch → Preheat & Heat Soak card:
master enable toggle (default off — disabled installs see no change),
per-filament chamber-target editor (replaces a single global int that
shipped in the first cut and couldn't serve PA + PLA in the same
config), preheat_max_wait_seconds, preheat_soak_seconds. The Print
Options panel in PrintModal gets a Preheat sub-section with the
tri-state Inherit/On/Off control and an optional chamber-target
override input.

DB migration: PrintQueueItem gains preheat_override VARCHAR(10)
DEFAULT 'inherit' and preheat_chamber_target_override INTEGER NULL.
Idempotent via _safe_execute. Existing rows behave exactly as before
the migration.

Best-effort throughout: printer drops, refused M141 or set_airduct,
missing bed temp, lost MQTT state mid-wait all log and return cleanly.
Normal upload + start path runs after this returns regardless.
2026-06-29 12:35:43 +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 a46b120336 Housekeeping 2026-06-28 13:40:52 +02:00
maziggy 4c79563630 fix(auth): API keys with Manage Library can curate library files (#1832)
require_ownership_permission gates API keys on `all_perm` only — the
    comment at auth.py:1659 says OWN and ALL "both map to the same scope
    flag" for queue / archives / etc., so checking `all_perm` is the
    correct gate. Library deliberately broke that: LIBRARY_UPDATE_OWN /
    LIBRARY_DELETE_OWN mapped to can_manage_library, but the ALL variants
    were in _APIKEY_DENIED_PERMISSIONS. Result — every API-key request to
    DELETE /library/files/{id}, PUT /library/files/{id} (rename), or
    POST /library/files/move hit "administrative operations" 403, even
    for keys with can_manage_library=True. Only slice worked, because it
    doesn't go through require_ownership_permission.

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

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

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

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

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

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

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

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

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

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

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

      Captured cleanly in the reporter's support bundle:

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

      immediately followed by gcode_state: RUNNING on the same wire.

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

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

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

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

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

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

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

      Three pieces:

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

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

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

      Frontend banner above the active Queue tab surfaces blocked printers,
      fires a warning-variant ConfirmModal, and shows a precise toast on
      success.
2026-06-28 12:44:49 +02:00
maziggy 71b0575ff9 fix(vp): close #1780 race — bump slicer-MQTT wait to 5s + retroactive stamp
Round 2 (166e9f9e) fixed the stash-key mismatch, but @mkoreen's
      2026-06-23 bundle showed BS's MQTT project_file arrived 85 ms past the
      2.0 s wait timeout (FTP done 00:42:02.509, "No slicer options cached"
      00:42:04.509, MQTT 00:42:04.594). Queue item was committed with
      settings defaults; nozzle_mapping never made it onto the wire.

      Three pieces:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

      Behaviour change worth flagging: users on Bambu Studio whose
      slicer-side bed-leveling / flow-cali / vibration-cali /
      layer-inspect / timelapse differ from Bambuddy's
      default-workflow settings will see their slicer choices
      honored now instead of silently overridden. Restores #1403's
      original intent.
2026-06-28 12:41:31 +02:00
maziggy 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 d1d166592c feat(heater-history): track nozzle / bed / chamber readings + per-tile chart-icon overlay opens history modal
New PrinterSensorHistory table + 60s recorder + GET/DELETE /printer-sensor-history
      route gated behind a new PRINTER_SENSOR_HISTORY_READ scope (separate from AMS). UI
      adds a 10x10 LineChart icon on each heater tile - click body opens the existing
      target-temp popover unchanged, click icon opens a HeaterHistoryModal mirroring the
      AMSHistoryModal shape (kind toggle + 6h/24h/48h/7d range + current/avg/min/max +
      recharts line for value + dashed target). Read-only X1C/P2S chamber tile finally
      gets an interaction. Retention configurable via printer_sensor_history_retention_days
      (default 30, sibling of ams_history_retention_days). 8 new i18n keys translated in
      all 11 locales, parity green. 4 backend + 6 frontend tests added; full pytest -n 30
      6226/6226, vitest 2176/2176, ruff/eslint/build all clean.
2026-06-28 12:37:49 +02:00
maziggy a70c2a2dc4 fix(usage-tracker): split mid-print AMS-Backup spool switch correctly (#1771)
Reporter forcefully started a print needing ~260 g with 180 g on the
      first spool and a backup spool in the AMS. Printer correctly consumed
      spool 1, AMS Backup switched, spool 2 finished the print. Bambuddy
      attributed all 260 g to spool 2 -- spool 1 untouched in inventory.

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

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

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

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

      Fix:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

      Dependencies: trimesh's 3MF loader uses networkx (scene-graph traversal)
      and lxml (model.xml parse) lazily inside the 3MF code path — both added
      to requirements.txt because they aren't strict trimesh transitives.
2026-06-28 12:34:22 +02:00
maziggy b118dd2687 test(settings): include preset fields in /ui-preferences pin assertion
Follow-up to the temperature & fan-speed presets feature — the
      TestUiPreferencesEndpoint.test_returns_expected_field_set test pins
      the exact set of fields the endpoint exposes (so adding a sensitive
      field by accident fails the assert). The 4 preset fields were added
      to _UI_PREFERENCE_FIELDS without updating the pin, breaking the full
      backend test run.
2026-06-28 12:34:05 +02:00
maziggy 6fa74be429 feat: Update printer card UI for structure and readability (#1661) 2026-06-28 12:33:05 +02:00
maziggy 18270b4a69 log(scheduler): emit prefer-lowest dispatch decision at INFO so #1766 can be triaged from a bundle
The matcher's tray_info_idx vs color vs type_only bucket choice was
      debug-only, so a bug report's bundle never showed which path won. Emit
      the sorted candidate trays and the picked bucket per filament req when
      prefer_lowest=True. Behaviour-neutral; existing 89 matcher tests pass.
2026-06-28 12:31:58 +02:00
maziggy 8283b175c0 Restrict printer secrets to update-authority callers
GET /api/v1/printers/ and /api/v1/printers/{id} return access_code
      only when the caller holds PRINTERS_UPDATE. Adds PrinterResponseWithSecret
      as the elevated response shape; PrinterResponse no longer carries the
      field. Auth-disabled single-trust mode preserved.
2026-06-28 12:31:11 +02:00
maziggy 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 2cbbd1eed3 fix(notifications): wire on_printer_offline dispatch on disconnect edge (#1752)
The provider toggle, schema, template, and NotificationService.on_printer_offline
      all shipped, but no caller invoked the dispatcher — the offline event was an
      orphan toggle. Edge detection in on_printer_status_change now schedules a
      debounced (60s) background task on the connected→disconnected transition;
      reconnect before the window elapses cancels it. Covers both upstream paths
      (smart-plug power-off via mark_printer_offline, and MQTT staleness via
      check_staleness), both of which already route through the status callback.
      The "back online" channel is the existing print-failure notification on
      firmware FAILED report — no symmetric on_printer_online needed.
2026-06-28 12:25:25 +02:00
maziggy 8faaeb96e0 fix(archives): backfill NULL created_at + tolerate NULL in response (#1732)
Older print_archives rows (and rows that landed via the SQLite ↔ Postgres
      cross-DB restore path) can have created_at = NULL because the column was
      originally created without a DEFAULT clause — server_default=func.now()
      only fires at table creation, not for existing rows or raw cross-DB
      inserts. The list_archives response model required a datetime, so a
      single NULL row 500'd the whole endpoint via Pydantic ResponseValidationError.

      - Boot-time backfill: COALESCE(completed_at, started_at, now()) for
        any row where created_at IS NULL. Dialect-branched (SQLite datetime('now')
        vs Postgres NOW()).
      - Schema: created_at is now Optional on ArchiveDuplicate, ArchiveResponse,
        and ArchiveSlim so a future NULL-leaking path doesn't break the list
        endpoint again.
2026-06-28 12:24:44 +02:00
maziggy b5a2f56cca fix(notifications): defer first-layer photo until printer is actually printing (#1837)
P1S and other Bambu printers tick layer_num during the pre-print calibration
sequence (homing -> auto bed leveling -> bed-surface scan -> nozzle clean ->
purge / wipe), so a bare `2 <= layer_num <= 5` gate fires the first-layer
notification minutes before the first real extrusion. The attached photo
shows a lowered bed, parked toolhead, and a clean plate -- exactly the
state during PREPARE, not after layer 1.

The reporter's log timeline made it explicit:
- 13:54:27  PRINT START detected
- 14:10:13  [SNAPSHOT] Capturing fresh frame  (notification fires here)
- 14:44:28  gcode_state: RUNNING (debug log, only visible because they
            enabled debug logging mid-print)

So the notification went out ~30 minutes before the print actually started.

Fix in main.py:6043 -- the on_layer_change first-layer block now requires
both:

  - state.state == "RUNNING" (gcode_state is RUNNING, not PREPARE)
  - state.mc_print_sub_stage in (None, 0)
    (0 = "Printing" in the canonical STAGE_NAMES at bambu_mqtt.py:376;
    None preserved as a no-opinion fall-through for any firmware that
    doesn't push the sub-stage so unknown-firmware installs keep
    their existing behaviour)

_first_layer_notified is only set after the gate passes, so calibration
ticks are non-consuming -- the next on_layer_change edge fires the
notification once the printer is actually printing.

The trigger window widens from [2, 5] to [2, 10] so that if calibration
consumed several layer_num slots before RUNNING, the deferred edge
still falls inside. The RUNNING + sub-stage gate ensures we don't fire
on a stale layer count.
2026-06-28 11:39:47 +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