Commit Graph
344 Commits
Author SHA1 Message Date
maziggy 39ce37885e Bumped version 2026-07-07 13:22:13 +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 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 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 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 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 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 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 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 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 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 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 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 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 11227f65b3 chore(deps): dompurify 3.4.10 -> 3.4.11 (GHSA-cmwh-pvxp-8882, moderate) 2026-06-28 12:35:24 +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 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 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 60569ee879 Version bump 2026-06-27 16:53:37 +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 46a3ee3235 Bumped version 2026-06-27 12:54:59 +02:00
maziggy 9033b0f81e feat(toast): restore upload-progress toast for scheduler dispatches (#1625 follow-up)
FTP push to the printer into the server-side scheduler tick. That
removed the browser-side upload the old XHR-progress modal listened
to — users only saw the queue item flip to "active" with no visibility
into the FTP push + the H2D/H2D Pro 80-210 s project_file digestion
window before the printer actually started.

Port the legacy bg-dispatch toast rendering from
0b43ac0d:frontend/src/contexts/ToastContext.tsx lines 510-650 back in
place verbatim — same DOM tree, same Tailwind classes, same
formatFileSize bytes line, same uppercase status chip, same collapse
chevron, same awaitingPrinter derivation, same auto-dismiss. The only
adapt is the event ingestion: a useEffect maps the four scheduler-side
WS events to the legacy DispatchToastJob shape.

The toast materializes when the FTP push to the printer ACTUALLY
STARTS (queue_item_uploading) — NOT on POST /queue. A draft that
fired at queue-add made the toast jump to "Dispatched" before any
upload had happened.

Four backend WS events drive it: uploading (carries printer_name +
total_bytes), upload_progress (throttled at 200 ms / 256 KB to match
legacy background_dispatch.py:614-615 1:1, first call always emits,
completion always emits; an _UploadProgressBridge bridges from the
FTP executor thread to the asyncio loop), acked (printer transitioned
out of pre_state), failed (with a reason key the toast looks up as
dispatchToast.failed.{reason}). No queue_item_dispatched event: the
legacy path kept status=processing from upload start until printer
ack, "Awaiting printer..." derives from upload_progress_pct >= 99.9
(legacy uploadDoneAwaitingPrinter trick).

Per-user routing: WS connect resolves the principal username to
User.id once and stashes it on websocket.state, so
ws_manager.broadcast_to_user filters O(connections). Auth-disabled
installs route user_id=None to all connections — matches the legacy
single-user behaviour. The watchdog receives created_by_id through a
new kwarg so the static method can still emit acked without
re-fetching the queue item.
2026-06-27 11:28:00 +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
Ed 4c67d8a4e1 feat: Unify print dispatch through the scheduler (#1625) 2026-06-26 12:31:48 +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 50b7d498d9 Post work PR #1814
fix(db): order filament_shopping_list color_name ALTER after CREATE

  PR #1814 added ALTER TABLE filament_shopping_list ADD COLUMN color_name
  before the CREATE TABLE IF NOT EXISTS for that table. On fresh installs
  the ALTER hit "no such table" — not in _safe_execute's swallow list —
  and aborted run_migrations, breaking every migration test that starts
  from a fresh DB. Moved the ALTER to after the CREATE on both SQLite and
  Postgres branches; the CREATE already declares color_name, so this is
  purely the upgrade path and "duplicate column name" on re-runs is
  swallowed.
2026-06-25 11:48:10 +02:00
Keybored 6c5b40dd57 [Fix] Forecasting: Group spools by color and rework UI (#1814) 2026-06-25 11:35:57 +02:00
maziggy e09a33be16 feat(spoolman): remain%-delta fallback for no-3MF "Untitled" prints (#1820)
Brings the Spoolman writer up to parity with the internal-inventory
  side, which has had this fallback since #1119. When a Bambu print
  starts without a retrievable .gcode.3mf on the printer (typically an
  unsaved BambuStudio project, subtask_name='Untitled'), Spoolman no
  longer silently skips the print's filament consumption.

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

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

  Mid-print spool swap detected via tray_uuid mismatch → slot skipped.
  Double-charge prevented via handled_global_tray_ids dedup.
2026-06-25 10:46:57 +02:00
maziggy 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 4dcd37bc87 feat(system): NTP-gate state on /api/v1/system/appliance
Extends the appliance endpoint that landed in the previous commit with a
  time_synced field, sourced from /run/bambuddy/time-synced (the appliance's
  ntp-gate.sh writes this once chronyd reports sync, or with a "warning"
  marker after the 3-minute timeout). The RPi 5 has no battery-backed RTC,
  so on a fresh boot the system clock is wrong until NTP catches up -- JWT
  expiries and TLS certificate validity windows depend on this being right.
  Exposing the gate lets the SPA render a "time not synced" indicator while
  that's still true and clear it once "ok" comes through.

  backend/app/core/local_config.py

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

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

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

  backend/app/api/routes/system.py

  /system/appliance now returns:

    {hostname, timezone, locale, time_synced}

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

  backend/app/core/local_config.py

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

  backend/app/api/routes/system.py

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

  frontend/src/i18n/index.ts

  One-shot applyApplianceLocale() runs after i18n.init(). Gated by a
  bambuddy_appliance_locale_consumed localStorage flag so it runs at most
  once per appliance. Fetches /api/v1/system/appliance, validates the
  returned locale against supportedLngs, calls i18n.changeLanguage if
  valid. Silent .catch() because the endpoint absent / unreachable means
  non-appliance install or dev environment -- we leave the LanguageDetector's
  choice in place. The consumed flag is set on success; future loads skip
  the fetch entirely. Won't override a user's explicit language pick (the
  language picker writes to a separate localStorage key, bambutrack_language).
2026-06-22 14:13:44 +02:00
maziggy 7e6b390d74 feat(notifications): template-driven finish-photo email embed + user_print_* rename (#1792)
Reporter (email provider, "Reason: unknown" failures) wanted a camera snapshot
  in failure emails. The finish-photo capture path shipped in 0.2.5b1 (#1397)
  already loads JPEG bytes into archive_data["image_data"] for terminal print
  events, and pushover/telegram/discord/ntfy users have been getting them.
  Email was the one provider that dropped the bytes on the floor. Reporter
  separately flagged that the Message Templates list shows "Print Completed"
  and "User Print Completed" with no visual cue they're different dispatches.

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

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

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

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

  2) user_print_* template name disambiguation.

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

     DEFAULT_TEMPLATES now seeds the four user_print_* rows with " Email"
     suffix so fresh installs are correctly labelled. New
     _migrate_rename_user_print_template_names runs on startup and updates
     existing rows where the name still matches the old default. Admin-edited
     names are preserved. Standard SQL UPDATE works on both SQLite and
     Postgres without dialect branching.
2026-06-22 11:15:39 +02:00
maziggy 4206d675eb feat(notifications): dedicate AI Failure Detection notification event (#1794)
Split Obico failure-detection dispatch out of the multiplexed
  on_printer_error event onto its own on_ai_failure_detection event so
  users can subscribe to AI alerts without also enabling HMS hardware-
  error pages, and so the discoverable label "AI Failure Detection" is
  what subscribes them rather than the unrelated "Printer Error" toggle.

  New column on notification_providers (default False, branched
  SQLite/Postgres migration), new notification_service.on_ai_failure_detection
  method, new ai_failure_detection template, obico_actions._notify swap.
  Frontend gets a summary badge, a toggle row with description, and ntfy
  priority surfacing. 14 new tests pin the routing + the regression guard
  ("Printer Error" alone must NOT receive AI notifications now). 11 locales
  covered.

  Existing providers keep working: HMS hardware errors continue to ride
  on_printer_error unchanged; users who want spaghetti alerts opt in via
  the new toggle.
2026-06-22 08:15:29 +02:00
maziggy 166e9f9ef2 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-21 14:09:05 +02:00
maziggy 090c180ebf 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-20 12:55:24 +02:00
maziggy d196cfc500 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-19 13:19:31 +02:00
phieb b414af6b1c feat(gcode-injection): per-VP opt-in auto-print injection toggle (#1516) (#1656) 2026-06-19 11:29:07 +02:00
Poltavtcev af5d24e289 feat(inventory): structured storage locations catalog (#1505) 2026-06-17 11:33:23 +02:00