Commit Graph
185 Commits
Author SHA1 Message Date
maziggy 56accd24de fix(smart-plugs): don't blank printer state when an accessory plug switches off (#2629)
An end-of-print auto-off on a plug that powers a filter fan marked the linked
printer offline and forced its state to "unknown". The mark was unrecoverable:
connected heals on the next MQTT message but state does not (only frames
carrying gcode_state rewrite it, and steady-state push_status frames are
partial), so the printer stayed "unknown" until a manual Force Refresh and the
queue never dispatched to it again.

The offline mark is now an explicit presumption: mark_power_off records the
state it overwrites and _on_message undoes it as soon as the printer sends
another report on its own topic, since inbound traffic proves the power was
never cut. A reconnect discards the saved state, so a genuine power cut is
unaffected. Each plug also gains a controls_printer_power flag (default true,
backfilled) that gates all five power-off paths, and the queue's power-on step
now picks the flagged plug instead of whichever linked plug came first.
2026-07-22 09:57:27 +02:00
maziggy 2e45893dd5 feat(print-options): add "Auto" state to bed levelling, flow & nozzle-offset calibration
Bed levelling, flow calibration, and nozzle-offset calibration were on/off
only, so the sole way to run bed levelling was to force a full level before
every print. Bambu Studio has always offered a third "Auto" state that lets
the printer skip the calibration when it was done recently -- the state most
users actually want. Make these three options tri-state (off/on/auto),
defaulting to auto, and leave vibration/layer-inspect/timelapse as on/off
(Bambu Studio exposes no auto for those).

Wire encoding follows Bambu Studio's source exactly: each option sends a JSON
bool (true only for "on") plus a companion int -- off=0, on=1, auto=2. The
bool fields stay booleans (the #1478 H2S regression); only the companion int
widened from {0,1} to {0,1,2}. #1721's observation that stage 8/39 stays
queued when sending 2 is the auto contract (queued, skipped at runtime if
recent), not a broken "off".

- schemas: TriState = Literal[off/on/auto] with a BeforeValidator coercing
  legacy bool / 0-1 / true-false so old clients and un-migrated rows validate
- model + migration: boolean columns -> String; SQLite via column affinity +
  data backfill, PostgreSQL via ALTER COLUMN TYPE guarded on information_schema
  (verified on both dialects); settings rows normalised true/false -> on/off
- MQTT: start_print takes the tri-state strings and emits the paired bool+int
- Virtual Printer: reconstructs the slicer's auto/on/off from the int companion
  (auto_bed_leveling / extrude_cali_flag) in both capture paths
- frontend: CalibrationMode type; off/auto/on segmented controls in the print
  dialog, queue bulk-edit, and Settings -> Workflow; calibrationMode_* strings
  in all 11 locales
2026-07-20 17:55:56 +02:00
maziggy 64f9d04c80 fix(queue): claim a queue item before dispatch so it can't be reassigned mid-upload (#2615)
A queue row stays status='pending' for the whole FTP upload; status only flips
to 'printing' at the end. The edit routes only blocked non-pending rows, so a
PATCH during the upload window was accepted while the in-flight dispatch kept
using its snapshotted printer -- splitting the queue row from the archive /
expected-print / physical command across two printers, and enabling a duplicate
dispatch on restart. The #1853 CAS guards cancellation, not reassignment.

Add a dispatching_at claim, stamped atomically (WHERE status='pending' AND
dispatching_at IS NULL) before any slow I/O and cleared on every exit. While
held, the single-item PATCH returns 409 (re-checked just before the write),
bulk edits skip the row, and the scheduler won't re-select it. Startup
reconciliation clears claims orphaned by a crash mid-dispatch. The row stays
pending throughout, so no status/UI/completion/reconciliation path changes.

New column print_queue.dispatching_at (nullable, dialect-safe DDL). Covered by
scheduler tests (claim exclusivity, non-pending rejection, release-on-exit,
skip-already-claimed, startup stale-clear) and API tests (reassign 409,
printer_id unchanged, bulk skip, unclaimed row still edits).
2026-07-20 12:30:39 +02:00
maziggy 83a7b75b14 fix(queue): persist selected plate to the archive; reconcile archive on offline stop (#2603)
A print queued from a specific plate of a multi-plate 3MF showed as Plate 1
in Print History after cancellation: the archive derives its plate from the
filename, but a whole multi-plate 3MF uploads under one name with no plate
suffix, so the parser defaulted to plate 1 and nothing copied the queue
item's plate_id onto the archive (which had no plate field).

Add a nullable print_archives.plate_id, copy it from the queue item at
dispatch (archive- and library-file paths), expose it in the archive API,
and render it in Print History. A startup backfill copies the plate onto
existing archives from their linked queue rows. Column add + backfill are
identical on SQLite and Postgres.

Also fix a related lifecycle bug: stopping a printing item while the printer
was offline left the linked archive stuck at "printing" (queue row
cancelled, but no MQTT completion ever arrives to reconcile the archive).
The offline-stop path now closes the archive out directly; the online path
still defers to the MQTT completion event.
2026-07-19 09:29:54 +02:00
maziggy 09b739b95d fix(cloud): stop reporting an expired Bambu Cloud sign-in as connected (issue #2562)
An expired token was indistinguishable from a working one. set_token()
stamped token_expiry = now + 30 days every time a stored token was loaded,
so the expiry reset on every request and is_authenticated could never
return False. /cloud/status answered "connected" for as long as any token
existed, while every cloud call 401'd — and the user was shown Bambu's own
{"error": "Please login."} verbatim.

Bambu is now the authority: /cloud/status validates the token upstream
(cached 5m), and any 401 from any authenticated call durably records the
credential as dead via users.cloud_token_invalid_at, so MakerWorld, cloud
profiles, slicer presets and firmware checks all agree at once. An
unreachable Bambu is treated as unknown, never as expired, so an outage
cannot sign a working session out.

The user-facing message now names the Profiles page, where the Bambu Cloud
sign-in actually lives; the old text pointed at a Settings page that does
not exist. Same stale path corrected in the wiki.
2026-07-14 11:29:56 +02:00
maziggy ce807fb1cc fix(queue): upload to printers in parallel, cap wedge retries, make debug logs survive a farm
The reporter's 19-printer farm started prints "one by one", up to an hour apart.
check_queue awaited each dispatch inline, and a dispatch includes the FTP upload,
so every printer queued behind every other printer's transfer despite being an
independent machine. His logs give the arithmetic: 40978500 bytes in 254.1s,
157 KB/s - a Bambu printer's SD write, not the network, is the bottleneck. Nineteen
of those in series is ~80 minutes, and the next upload started 131 ms after the
previous one finished. The delay is linear in fleet size, which is why it got worse
the more printers he selected.

Dispatch is now collected during the (still sequential) selection loop and run
concurrently afterwards, capped by queue_max_concurrent_uploads - Settings ->
Workflow -> Queue & Dispatch, default 4, 1 restores the old behaviour. Every gate
is untouched; only the transfers overlap. The pass still awaits its uploads before
returning: _start_print flips the row pending -> printing only after the upload,
so an early return would let the next tick re-dispatch the same rows.

FTP work moves to its own thread pool. It was on asyncio's default executor -
min(32, cpu+4), six threads on a 2-core NAS, shared with everything else - which
was survivable only while uploads were serial.

Two problems the same bundle exposed:

A printer that accepts project_file but never starts (#1678) was retried forever:
270s watchdog, revert to pending, re-upload the whole file, repeat. Hence his
"printer who, since the morning, still not launch" - and on a farm each lap also
eats an upload slot the other printers are waiting on. Attempts are now counted on
the queue item; after three it fails with a message pointing at the printer instead
of queueing a fourth re-upload.

The debug bundle we asked him for held 4m49s of history. The push_status dumps fired
on every frame rather than on change - several while their own comment claimed
otherwise - which is 27,727 of the bundle's 29,830 lines and rolls 5 MB in under five
minutes on 19 printers. They now log transitions only. The bundle also read just the
live log while three rotated backups sat next to it, under a byte budget four times
larger than the file it was reading.

Migration verified on SQLite and Postgres: idempotent, backfills legacy NULLs
(dispatch_attempts + 1 is NULL for a NULL row, which would silently disable the cap).

Tests: 6 on concurrent dispatch (overlap, cap honoured, 1 == serial, default applies
with no settings row, a failed printer does not cancel its siblings, no early return),
4 on the retry budget, 6 on the bundle's rotated-log span, 7 on the debug gating.
Each verified to fail against the unfixed code - the first end-to-end log assertion I
wrote passed without the fix and had to be tightened.
2026-07-14 10:35:58 +02:00
maziggy aba00598bb fix(smart-plugs): read a REST plug's lifetime counter, and derive Today/Yesterday from it (issue #2539)
A Shelly reports one energy figure — aenergy.total, a lifetime counter in Wh
that never resets. Bambuddy had a single REST energy field and filed whatever
it found under "today", so the value never reset at midnight, and Yesterday
and Total stayed at zero: get_energy() simply never set those keys.

With `total` unpopulated, the hourly snapshot recorder skipped the plug, so
the Statistics page's energy figure was zero as well, not just the Settings
card.

Split the REST energy config in two: rest_energy_path still means "used
today", rest_energy_total_path means "lifetime counter". A Shelly has only
the latter; a Tasmota behind a REST bridge has both; sharing a URL costs one
fetch, not two.

Then derive Today and Yesterday from that counter using the snapshots we were
already taking: today = counter now - counter at the last local midnight;
yesterday = the gap between the two previous midnights. Local midnight, not
UTC — a UTC boundary rolls Today over at 02:00 in Berlin. The snapshot loop
now ticks on the local hour so a reading lands on the boundary instead of up
to an hour early. A counter that goes backwards (factory reset) reports
nothing rather than a negative.

Collateral, found while verifying on both engines: the smart-plug DateTime
columns are naive UTC but the code wrote aware datetimes into them. SQLite
drops the offset; asyncpg raises DataError. So on Postgres every snapshot
capture raised inside the loop's except, and every status poll raised on
last_checked — the whole subsystem was dead on the database we recommend for
multi-printer installs. All plug timestamps are naive UTC now.

Existing REST users with a cumulative path in the today field must move it to
the new lifetime field; the form and wiki now name which counter each wants.
2026-07-11 14:22:31 +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 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
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
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 bb42b423af feat(file-manager): user-authored tags for cross-cutting filtering (#1268)
Third and final piece of #1268, alongside the recursive-search +
  README-panel commit that landed earlier in 0.2.5b1. Folders express
  hierarchy (one home per file); tags are orthogonal labels — "toy",
  "kid-safe", "petg-only" — and a single file can carry as many as the
  user wants. Reporter wanted to find "every toy regardless of which
  folder it lives in"; folders alone can't do that without forcing the
  file into one bucket.

  Design decisions locked with maziggy before code:

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

  Backend

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

  Frontend

    - LibraryTagsModal (catalog CRUD) opens from the toolbar's new
      Tags button. max-w-4xl so multi-language subtitles don't wrap.
      Delete-with-warning when file_count > 0 ("removes the chip from
      all of them; files themselves are untouched").
    - BulkTagsPickerModal opens from the multi-select toolbar (new
      Tag button between Move and Delete). Add/Remove radio,
      checkbox list, inline "create new tag" disabled on dup.
      Apply disabled until at least one tag is selected. The replace
      action is exposed in the API but deliberately NOT in this UI —
      arbitrary multi-file replace is destructive and confusing.
    - FileManagerPage integration:
        * selectedTagIds state, sorted into the useQuery key so the
          cache hits are stable regardless of toggle order
        * filter rail above the file list lists EVERY catalog tag as
          a togglable chip — inactive outlined, active filled green
          with an X. Clear all when 1+ active. Bar hidden entirely
          when catalog is empty.
        * useEffect prunes selectedTagIds when a tag is deleted from
          the catalog so the filter never strands on a phantom id
        * dedicated Tags column in list view at minmax(0,200px)
          between Prints and Actions
        * grid view chips render below the metadata block
        * chip clicks stop propagation so they don't toggle file
          selection
    - libraryTagsQueryKey extracted to frontend/src/utils/
      libraryTagsQuery.ts so component files export only components
      (Vite react-refresh rule).
    - LibraryFileListItem.tags is OPTIONAL even though the backend
      always emits an empty array — legacy msw mocks in pre-existing
      tests construct partial file shapes without the field. Without
      the ? the FileCard renderer crashed on .length and broke 49
      unrelated tests across FileManagerPage + FileManagerExternalFolder.
      Read sites use file.tags ?? [].
2026-06-22 12:27:58 +02:00
maziggy 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 a5fe5cb3d4 feat(sponsor-prompt): in-app toast at earned milestones
ghcr.io pull baseline (~10k/day rising → ~8-12k active installs) puts
  sponsor conversion at 0.08% — roughly an order of magnitude under
  industry-benchmark for OSS with visible CTA. The Settings banner from
  0d4b9d4e gives passive every-visit visibility on one page; this adds
  opt-out-able active visibility at moments where the user has just
  earned something with Bambuddy.

  Five trigger families with a 14-day cross-family cooldown: prints
  (100/500/1000/2500/5000), cost (100/500/1000 tracked filament +
  energy), archives (50/250/1000), anniversary (1 year), version-update
  (re-armable on each major bump). New sponsor_toast_state table with
  nullable user_id so auth-disabled installs get the same trigger logic
  through one code path (NULL-keyed install-default row).
2026-06-20 15:50:58 +02:00
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
maziggy 85fbd7fc35 fix(queue): persist "Print Anyway" so scheduler stops re-flagging it
When the user clicked Print Anyway on a filament-deficit warning, the
  acknowledgement was one-shot. The route cleared manual_start and
  filament_short, then the next scheduler tick re-ran
  compute_deficit_for_queue_item against identical spool state, found
  the same deficit, and re-set both flags. The item bounced between
  "user said anyway" and "scheduler re-blocked" — every Play click
  returned 409, every confirm got rolled back on the next tick.

  Add a persistent acknowledgement flag on the queue item:

  - New column `skip_filament_check` on print_queue. SQLite + Postgres
    migration branched on is_sqlite() so Postgres doesn't reject
    DEFAULT 0 on BOOLEAN.

  - PrintQueueItemCreate + PrintQueueItemResponse schemas + the
    TypeScript types carry the field.

  - POST /print-queue/{id}/start with skip_filament_check=true now
    ALSO sets item.skip_filament_check = True (not just clearing
    manual_start / filament_short).

  - PrintScheduler._block_on_filament_deficit short-circuits to
    False — no compute, no flag-setting, no notification — when
    item.skip_filament_check is True. We trust the operator's
    decision and stop fighting them.

  - PrintModal at queue-creation time threads
    skip_filament_check=true into the create payload when the user
    clicks Print Anyway on the frontend deficit warning, so a print
    that was warned-then-acknowledged at add-to-queue time goes in
    pre-acknowledged — scheduler never blocks it on first tick.

  Flag is not auto-cleared on spool swap by design: if remaining is
  now sufficient, the check returns no deficit anyway, so the flag
  is moot. Auto-clearing would add lifecycle complexity without
  changing behaviour.

  AMS Backup awareness (the other half of the discussion) intentionally
  NOT included — verified the H2D's bit-26 of print.cfg toggles with
  the printer-side AMS Backup setting, but the X1C's cfg has a
  different shape entirely and verifying every model family isn't
  realistic. Silently under-warning would be worse than always
  per-slot. The check stays single-slot for now.
2026-06-09 10:43:07 +02:00
maziggy 2c2725cb53 fix(print): expose nozzle_offset_cali toggle for dual-nozzle printers (#1682)
Bambuddy's project_file MQTT payload hardcoded "nozzle_offset_cali": 2 (skip),
  giving users on H2D / H2D Pro / H2C / X2D no way to control the same toggle
  BambuStudio exposes. Critical for diamond-nozzle setups that must keep the
  calibration off.

  start_print() now takes a nozzle_offset_cali kwarg; the value is encoded as
  1 (run) or 2 (skip) and gated on is_dual_nozzle so single-nozzle machines
  always send 2 even if a stale flag arrives. The kwarg threads through
  printer_manager, both background_dispatch sites, and print_scheduler so
  every dispatch path respects the per-item setting.

  print_queue gains a nozzle_offset_cali column (DEFAULT TRUE, is_sqlite()
  branch for Postgres BOOLEAN). Settings default key default_nozzle_offset_cali
  defaults to TRUE to match BambuStudio. Schemas updated across print_queue,
  library FilePrintRequest, archive ReprintRequest, settings.

  PrintModal renders the new toggle only when the selected printer is dual-
  nozzle (printer-mode: nozzle_count===2; model-mode: DUAL_NOZZLE_MODELS).
  SettingsPage default-print-options row + QueuePage bulk-edit tri-state both
  hide unless any registered printer is dual-nozzle. Labels reuse the existing
  settings.default* keys so the only new i18n strings are
  settings.defaultNozzleOffsetCali / Desc and queue.bulkEdit.nozzleOffsetCali
  - real translations in all 11 locales.
2026-06-08 09:20:19 +02:00
maziggy 12d17bfbe7 fix(photo): source finish photo from forced timelapse + cleanup (#1397)
Bambu's end-gcode lowers the bed at gcode_state=FINISH. Bambuddy's
  live-camera grab captured the bed already dropped, ruining the photo
  framing. Source the photo from a brief Bambu timelapse instead —
  firmware stops timelapse recording AFTER toolhead parks but BEFORE
  bed-drop runs, so the last frame frames the finished print correctly.

  When capture_finish_photo is on AND the user did not opt in to
  timelapse for this print, force timelapse=True at dispatch + mark the
  new PrintArchive.bambuddy_forced_timelapse column. After extraction
  (success or failure), cleanup deletes the locally-attached file,
  clears archive.timelapse_path, and walks the four scanner directories
  (/timelapse, /timelapse/video, /record, /recording) trying FTP DELE
  against the original filename. User-opted-in timelapses pass through
  unchanged.

  Resolver lives at services/background_dispatch.py::resolve_effective_timelapse
  (module-level so the print queue can reuse it). Both dispatch paths
  wired: background_dispatch.py (Print Now / Reprint) AND
  print_scheduler.py:_start_print (the queue). Field testing caught the
  scheduler gap on the first round — AST regression test now asserts
  start_print(timelapse=...) references effective_timelapse, not the raw
  item.timelapse, so a future refactor can't silently drop it.

  Extractor: ffmpeg -i input.mp4 -update 1 -q:v 2 out.jpg. Decoded
  frames overwrite the same output file, so the file left on disk is the
  literal last frame regardless of duration. Bambu records one frame per
  layer-change, so a 16-layer cube produces a 0.6 s timelapse — the
  original -sseof -1.0 approach seeked before the start of the file and
  returned frame 0 (empty bed). Decoding every frame is fine; Bambu
  timelapses are short by construction even on hours-long prints.

  Migration adds bambuddy_forced_timelapse branched on is_sqlite()
  (DEFAULT 0 / DEFAULT FALSE — PG rejects DEFAULT 0 for BOOLEAN).
  Verified live on postgres:16-alpine.

  Photo-task wait_for budget extends 45s -> 75s when timelapse_was_active
  so the notification carries the bed-up photo instead of falling back
  to the live-cam grab on slow links.

  Scope limit, documented in the camera wiki: prints started directly
  on the printer touchscreen / Bambu Handy / Bambu Studio Send bypass
  both dispatch paths, so the override doesn't fire there. Future
  option: mid-print M981 S1 P20000 MQTT toggle in on_print_start.

  Setting description rewritten in all 11 locales to drop the "only
  works when timelapse enabled" caveat (Bambuddy now forces it) and
  explain the kept-or-deleted behaviour.
2026-06-05 13:53:26 +02:00
maziggy 18d534c945 feat(orca-cloud): integrate Orca Cloud profile sync across UI, slicer and SpoolBuddy
Reads, lists, and slices with profiles from a user's Orca Cloud account
  (OrcaSlicer 2.4.0-alpha's Supabase-backed sync) alongside the existing
  Bambu Cloud integration. Four sign-in providers (Google / Apple / GitHub /
  email+password); password defaults. Paste-flow PKCE because Orca's
  Supabase project only allowlists localhost redirect_to — open feature
  request at OrcaSlicer/OrcaSlicer#14028.

  Surfaces:
  - Profiles tab: new "Orca Cloud" tab next to "Bambu Cloud" with the same
    rich layout (search + 5 filter dropdowns + 3-column grouped grid +
    read-only detail modal)
  - SliceModal: 4-tier preset picker (orca_cloud > local > bambu cloud >
    standard); separate status banner per cloud; metadata-aware pre-pick
    scores Orca filaments above local (Orca's sync_pull returns full
    content inline so filament_type / filament_colour come for free, no
    per-setting fetch rate-limit dance)
  - ConfigureAmsSlotModal: orca_cloud as a new preset source (prefixed
    orca_<UUID> to match local_/builtin_); generic Bambu filament-ID
    derivation from parsed material (printer firmware can't grok Orca
    UUIDs); slot mapping persists preset_source='orca_cloud'
  - SpoolForm / SpoolBuddyWriteTagPage: Orca filaments merge into the
    cloud preset list via Promise.allSettled (OrcaProfileMeta is
    structurally identical to SlicerSetting)

  Backend:
  - services/orca_cloud.py: OrcaCloudService with PKCE / token exchange /
    single-use refresh rotation / get_user_info / list_profiles via the
    bare /sync/pull bootstrap path
  - routes/orca_cloud.py: 7 endpoints (auth/start, auth/finish,
    auth/password, status, logout, profiles, profiles/{id}); router-level
    _cloud_api_key_gate + per-route cloud_caller() so API-keyed callers
    (SpoolBuddy kiosk) properly resolve their owner User; just-in-time
    refresh with atomic persist-before-API-call
  - routes/slicer_presets.py: _fetch_orca_cloud_presets mirrors the Bambu
    Cloud fetcher (status vocabulary, 5min cache, permission shortcut);
    _dedupe_by_name extended to 4 tiers; UnifiedPresetsResponse gains
    orca_cloud + orca_cloud_status
  - services/preset_resolver.py: PresetRef.source extended with
    "orca_cloud"; _resolve_orca_cloud walks list + filters
  - 8 columns on users table for tokens (5 persistent) + transient PKCE
    handshake state with 10-min TTL (3); dialect-branched DATETIME /
    TIMESTAMP; auth-disabled mode falls back to Settings table
  - orca_cloud:auth permission folded into can_access_cloud API-key scope
    (same trust dimension)
2026-06-04 15:50:44 +02:00
maziggy 5d6d928b3f fix(virtual-printer): #1429 net.info[*].ip cache leak + mode wire-value rename
#1429 (reported by @TrickShotMLG02, confirmed by @Mape6 on a flat single-LAN
  that rules out subnet / mDNS-reflector theories): with the physical printer
  off the slicer's "Send" landed in Bambuddy's archive; once the printer
  powered on every subsequent "Send" went straight to the printer's SD card
  and bypassed Bambuddy. Bundle analysis: mape6-before showed clean FTP
  receive + archive lines, mape6-after had zero FTP attempts to Bambuddy
  once the printer was online.

  Cause: mqtt_bridge.py::_resolve_client encoded _target_ip_uint32_le /
  _vp_ip_uint32_le ONLY on client-identity change and early-returned on
  every refresh tick when the same client object was still bound. If
  target_client.ip_address was empty at first bind (DB row stale, or client
  constructed before SSDP refresh filled it in), the encoding stayed None,
  the net.info[*].ip rewrite block was skipped, the cache filled with the
  real printer IP, sticky-key preservation kept the poisoned net value
  alive across every subsequent incremental push, and the slicer followed
  the leaked IP. Only Bambuddy-restart-with-printer-off cleared it — the
  workaround both reporters independently arrived at. Same shape on
  multi-NIC printers (X1C, H2D Pro): the rewrite only matched entries
  whose ip equalled _target_ip_uint32_le, so a secondary interface IP
  Bambuddy never saw would leak through unchanged.

  Bridge fix:
  - _resolve_client calls a new _refresh_ip_encoding() on every refresh
    tick, even when client identity is unchanged; self-heals once
    ip_address becomes valid.
  - _refresh_ip_encoding() sweeps the existing _latest_print_state when
    encoding becomes valid for the first time. Without the sweep,
    sticky-key preservation keeps the pre-arm poisoned cache alive
    forever — incremental pushes that don't include net carry the bad
    value forward.
  - _rewrite_net_info_ips() rewrites EVERY non-zero net.info[].ip entry
    that doesn't already equal the VP IP, not just entries matching
    _target_ip_uint32_le. Multi-NIC printers stop leaking secondary
    interfaces. Zero-IP placeholders are left alone so "active interface"
    detection still works.
  - INFO logging on encoding arm/update and on cache sweep so future
    bundles directly answer "did the rewrite fire?".

  Mode wire-value rename (#1429 follow-up, separate confusion source):
  - Both reporters' support bundles showed mode: immediate while the UI
    said "Archive"; @TrickShotMLG02 quoted: "I have no idea why it says
    immediate in the support-info.json file. In the webui the printer is
    set to archive". UI button "Archive" had always saved immediate, and
    "Queue" had always saved print_queue. Canonical wire values are now
    archive / review / queue / proxy, matching the button labels 1:1.
  - New normalize_vp_mode() + VP_MODE_* constants in
    models/virtual_printer.py; manager.py normalises on construction so
    a legacy row read pre-migration still dispatches correctly.
  - core/database.py::run_migrations rewrites existing virtual_printers
    and settings rows; idempotent (re-runs are no-ops); identical SQL
    under SQLite and Postgres.
  - API routes accept both legacy and canonical on input, normalise
    before storage. GET /settings/virtual-printer normalises on read so
    the frontend's mode-button highlight works for stale legacy values.
  - Three frontend VP components (VirtualPrinterSettings,
    VirtualPrinterCard, VirtualPrinterAddDialog) switched click handlers
    and type aliases to canonical; each got its own normalizeMode()
    helper so a stale-cached settings payload still highlights the right
    button. Two pre-existing `printer.mode === 'queue' ? 'review'`
    legacy mappings in VirtualPrinterCard were the source of a test
    failure caught mid-implementation where the new canonical 'queue'
    was being mis-aliased back to 'review' and hiding the auto-dispatch
    + force-color-match toggles.

  mode handler is NOT the dispatch bug: manager.py::_archive_file (the
  handler for archive mode) doesn't dispatch to the physical printer.
  The "files end up on the printer's SD card" symptom was the IP-leak
  from the bridge cache. The mode rename is purely clarity / support-
  bundle accuracy.
2026-06-02 14:33:20 +02:00
maziggy ec51394196 fix(security): GHSA-r2qv-8222-hqg3 — allowlist API-key permissions (CVSS 9.9)
API-key permission gates went from a 17-entry admin denylist with the three
  documented scope flags (can_read_status / can_queue / can_control_printer)
  enforced only inside /api/v1/webhook/* to an explicit per-Permission
  allowlist consulted by every dependency:

    - core/auth.py: _APIKEY_SCOPE_BY_PERMISSION maps every non-admin
      Permission to one scope flag on APIKey; unmapped = 403.
      _check_apikey_permissions now takes the api_key and checks the flag.
    - require_any_permission_if_auth_enabled + require_ownership_permission
      were returning None for any valid key with zero scope check; both now
      invoke _check_apikey_permissions and fail closed.
    - Two new scope flags on api_keys: can_manage_library (LIBRARY_UPLOAD /
      UPDATE_OWN / DELETE_OWN / MAKERWORLD_IMPORT) and can_manage_inventory
      (INVENTORY_CREATE / UPDATE / DELETE / FORECAST_WRITE — required by
      SpoolBuddy kiosks). Default TRUE, backfilled from can_queue so existing
      "queue-only" keys keep working and hardened "read-only" keys do not
      silently gain writes.
    - CLOUD_AUTH now routed through can_access_cloud for defence-in-depth
      alongside the existing _cloud_api_key_gate.
    - Migration column-existence check (_api_keys_column_exists) gates the
      backfill so user-edited values are never overwritten on restart.

  Structural drift backstop: test_every_permission_has_a_classification fails
  CI on any new Permission added without an explicit scope mapping —
  prevents the denylist-shape regression that grew the prior surface.

  Backend 5469 tests green; ruff clean. Frontend build green; i18n parity
  green across 9 locales (5005 leaves each, +6 new keys). Wiki permissions
  table + allowlist callout + upgrade notes updated.
2026-06-02 08:28:24 +02:00
maziggy 554a73070f fix(maintenance): paused prints no longer accumulate runtime hours (#1521)
PAUSE counted toward runtime_seconds equally with RUNNING, inflating
  hours-based maintenance thresholds (rod lube, belt check, nozzle clean)
  by however long overnight or extended pauses lasted. Maintenance items
  track mechanical wear, which is zero while paused, so the predicate
  now excludes PAUSE. Field-comment and docstring trail across main.py /
  models/printer.py / maintenance.py updated to match. Existing
  runtime_seconds values cannot be retroactively split — only future
  accumulation is fixed.

  Adds 3 regression tests pinning PAUSE non-accumulation, RUNNING
  accumulation, and the FINISH state's last_runtime_update clear
  (prevents idle-time back-bill when the printer next goes RUNNING).
2026-05-25 09:04:47 +02:00
maziggy 7ea4410b21 fix(queue): insufficient-filament warning now fires on every dispatch path (#1496)
The pre-print deficit warning from #720 only ran inside the PrintModal
  submit flow. Both the green ▶ button on a staged queue row (POST
  /queue/{id}/start) and the Virtual Printer queue-mode intake bypassed
  it — auto_dispatch=True VP intakes would dispatch unsupervised onto
  spools that physically can't complete the print.

  Extracted the deficit check into backend/app/services/filament_deficit.py
  (single source of truth, both internal inventory and Spoolman modes).
  POST /queue/{id}/start returns 409 with a structured deficit payload
  unless ?skip_filament_check=true. The dispatch scheduler runs the same
  check before each _start_print; a deficit promotes the item to
  manual_start + sets a new filament_short flag (idempotent migration on
  print_queue). The flag clears automatically on the next tick when the
  operator swaps a spool to one with enough material.

  Frontend ▶ catches the 409 and opens a confirm modal showing each
  shorted slot's required vs remaining grams; the row now renders a
  yellow "Insufficient filament" badge when filament_short is set.
  Translated across all 9 locales.
2026-05-23 08:47:46 +02:00
maziggy e61a454a0f fix(inventory): "Reset usage to 0" preserves remaining in both modes (#1390)
Reporter saw a 544 g spool jump to 1000 g after pressing the eraser.
  "Spools and remaining weights are not changed" - the dialog promised
  this; the implementation did the opposite. Root cause was an
  architectural conflation: `weight_used` did double duty as the
  resettable "consumed since tracking started" counter AND as the basis
  for the displayed remaining (`label_weight - weight_used`), so zeroing
  it correctly cleared the stat but unavoidably reset remaining to full.

  Spoolman has separate `used_weight` and `remaining_weight` fields, so
  the API call there was correct - but Bambuddy's frontend was also
  computing remaining as `label_weight - weight_used` for Spoolman
  spools (ignoring Spoolman's real `remaining_weight` field), so the
  same visual bug bit there too. Inventory-mode parity required fixing
  both halves in one drop.

  Internal mode

  - New `weight_used_baseline` column (Float DEFAULT 0) on `spool`.
  - Reset stamps `baseline = weight_used` and leaves `weight_used` alone.
  - Displayed consumed = `weight_used - baseline`; remaining =
    `label_weight - weight_used` (unchanged).
  - Subsequent prints continue to grow `weight_used`, so the resettable
    counter naturally tracks post-reset delta and remaining keeps
    decrementing across the reset.

  Spoolman mode

  - `_map_spoolman_spool` now reads Spoolman's `remaining_weight` field
    and returns a synthetic `weight_used = label - remaining` so the
    frontend's remaining calc matches Spoolman's real stored value;
    `weight_used_baseline = synthetic - real_used_weight` so the consumed
    counter (`weight_used - baseline`) matches Spoolman's `used_weight`.
  - Fallback path (no `remaining_weight` set) preserves the old behavior.
  - Related fix: `update_spool` (Spoolman PATCH) was deriving the default
    `weight_used` from `used_weight`, so editing unrelated fields AFTER
    a reset would patch Spoolman with `remaining_weight = label - 0 =
    label`, trampling the real value. Now derives from
    `remaining_weight` so non-weight edits preserve physical state.

  Frontend

  - `InventoryPage` `totalConsumed` aggregate switched to
    `Math.max(0, weight_used - (weight_used_baseline ?? 0))`.
  - `ForecastPanel` `computeDeltaRate`, `totalUsedG`, and the per-spool
    "consumed" table cell got the same treatment so forecast and
    inventory aggregates stay coherent across a reset.
  - `?? 0` keeps pre-migration installs rendering correctly until
    `init_db()` runs the idempotent ALTER TABLE.

  Migration

  - `ALTER TABLE spool ADD COLUMN weight_used_baseline REAL DEFAULT 0`
    via `_safe_execute` - SQLite and Postgres both accept it; verified
    end-to-end on Postgres 16.
2026-05-18 08:51:27 +02:00
maziggy 6f2cec5eb3 feat(smart-plugs): auto-off after AMS drying completes (#1349)
Reporter Kyobinoyo asked for the equivalent of the existing
  print-finish auto-off but triggered when AMS drying ends.

  Two new SmartPlug columns: auto_off_after_drying (default false),
  off_delay_after_drying_minutes (default 10 — AMS chamber is hot
  post-cycle so longer cooldown than the print-finish default of 5).
  SQLite + Postgres migrations both idempotent.

  Trigger lives in BambuMQTTClient — per-AMS _previous_dry_times
  tracks the dry_time > 0 → 0 falling edge and fires a new
  on_drying_complete(ams_id) callback. Plumbed through
  PrinterManager.set_drying_complete_callback to
  SmartPlugManager.on_drying_complete(printer_id, db), which walks
  linked plugs and respects the per-plug toggle. Catches queue,
  ambient and manual drying identically because it observes firmware
  state, not scheduler intent.

  Frontend: single "Auto Off After Drying" toggle + delay input on
  the smart plug card, next to the existing print-finish auto-off
  section.

  Per-AMS plug routing (separate plug for AMS only, per-AMS targeting
  on dual-AMS printers) deferred — Bambuddy's plug model is
  plug→printer, so the trigger fires whenever any AMS on the linked
  printer finishes a cycle.
2026-05-17 12:26:42 +02:00
maziggy 856b849ffa fix(stats): per-event aggregation so reprints add to Quick Stats instead of overwriting (#1378)
Statistics now aggregate over PrintLogEntry (one row per print event,
  the same table backing the global Print Log) rather than PrintArchive
  (one row per file). A reprint creates a new PrintLogEntry instead of
  overwriting the source archive's runtime fields, so:

  - a 100 g successful print + a 10 g failed reprint correctly sums to
    110 g / 2 prints / 1 successful / 1 failed in Quick Stats and the
    Prometheus /metrics endpoint (previously the failed reprint silently
    replaced the source archive's data; totals dropped from 100 g to 10 g)
  - the archive's card cost/energy_kwh are preserved on reprints (only
    the first run writes them); per-run actuals live on PrintLogEntry
  - failed/cancelled/stopped reprints record partial-aware filament: sum
    of tracked spool deltas when inventory is set up, else estimate
    scaled to progress%, else None — prevents the full slicer estimate
    from inflating totals on a print that stopped at 10 % progress

  PrintLogEntry gains six columns: archive_id (nullable FK, ON DELETE
  SET NULL so log entries survive archive deletion preserving #1343
  soft-delete-vs-stats decoupling), cost, energy_kwh, energy_cost,
  failure_reason, created_by_id. Idempotent SQLite + Postgres migrations.

  New per-archive surface:

  - archive list response carries run_count / last_run_at /
    total_filament_actual_grams / successful_run_count / failed_run_count
    via a single batch JOIN, no N+1
  - new GET /archives/{id}/runs endpoint returns every PrintLogEntry for
    the archive (ARCHIVES_READ permission, newest-first ordering)
  - archive cards render an orange "N prints" badge for archives with
    more than one run; clicking the badge opens a dedicated PrintLogModal
    with date/status/duration/filament/cost columns plus failure_reason
    under failed runs. Also reachable via the context menu's new "Print
    Log" entry (works for single-run archives too), and embedded at the
    top of the Edit Archive modal for context.

  The purge_stats=true delete path now hard-deletes linked PrintLogEntry
  rows up front so the archive's contribution truly leaves the totals;
  without it, ON DELETE SET NULL would orphan the runs and leave them
  counting toward stats.
2026-05-16 11:34:40 +02:00
maziggy ae29a7dcd3 fix(api-keys): expose narrowly-scoped "Update electricity price" toggle (#1356)
Reporter @maziggy followed the Energy Tracking wiki literally - "create a
  key with Write Settings permission, PATCH /api/v1/settings with
  {energy_cost_per_kwh: ...}" - and hit:
  {"detail":"API keys cannot be used for administrative operations"}.

  Triage showed three independent drifts:
  1. Wiki listed nine fictional API-key permissions (Read Printers / Write
     Settings / Admin / ...) but the UI only ever exposed four toggles
     (Read Status, Manage Queue, Control Printer, Allow Cloud Access).
     There was no Write Settings toggle to tick.
  2. Even if it had existed, the backend hard-denies SETTINGS_UPDATE for
     every API key via _APIKEY_DENIED_PERMISSIONS - intentional protection
     because PATCH /settings can rewrite SMTP/LDAP/MQTT credentials and the
     HA access token. Wider surface than any documented use case needs.
  3. So the wiki had been promising a workflow that was never deliverable.

  Fix: introduce a narrowly-scoped door rather than relax the deny list.

    - New column can_update_energy_cost (default FALSE - existing keys
      never silently gain settings-write capability on upgrade).
    - New route POST /api/v1/settings/electricity-price accepting
      {"energy_cost_per_kwh": <float >= 0>}. Field name matches what the
      wiki already documented so the HA rest_command example needs only a
      URL+method change, not a payload change.
    - Custom dependency require_energy_cost_update() bypasses
      _APIKEY_DENIED_PERMISSIONS for this one route for API keys with the
      flag set. JWT users still go through standard SETTINGS_UPDATE.
    - General PATCH /settings remains denied for API keys - flipping the
      narrow flag does NOT widen general settings-write access. Pinned by
      test_patch_settings_still_denied_with_energy_flag.

  Frontend: fifth "Update electricity price" toggle on the create-API-key
  card + amber "Energy" badge on existing keys with the flag set. Three
  new i18n keys across all 8 locales (German translated, English fallbacks
  elsewhere).
2026-05-15 13:12:41 +02:00
maziggy 9ba1e34729 fix(archives): keep Quick Stats contribution when deleting a print (#1343)
Reported by @IndividualGhost1905: printing the same model ten times and
  then deleting nine archive entries (to keep the file list tidy) silently
  rewound the totals on the Statistics page — total prints, filament,
  cost, and per-print energy all dropped back to whatever the surviving
  row contributed, as if the other nine prints had never happened.

  Root cause: every metric in get_archive_stats is recomputed live from
  PrintArchive rows via COUNT / SUM, so removing a row removes its
  contribution. Energy in the default "Total" mode already survived
  deletion because it reads the smart-plug lifetime counters — that's
  the architectural shape we now generalise to the rest.

  Fix: soft delete with opt-in hard purge.

  Backend:
  - New nullable, indexed deleted_at column on print_archives, dialect-
    conditional migration (DATETIME on SQLite, TIMESTAMP on PostgreSQL).
  - ArchiveService.soft_delete_archive flips deleted_at and removes the
    files from disk (still reclaims storage); the path-safety checks were
    extracted into _resolve_archive_dir_for_delete so soft and hard delete
    share the rules.
  - DELETE /archives/{id} accepts ?purge_stats=true; default is soft.
  - Listings filter deleted_at IS NULL: list_archives, search FTS + LIKE
    fallback, GET /{id} (404 on soft-deleted), tag listing, duplicate
    detection (so a 1-live + 9-soft-deleted group no longer marks the
    survivor as a duplicate), and ArchiveComparisonService's "similar"
    suggestions. GET /stats and GET /slim deliberately do NOT filter so
    Quick Stats and the dashboard widgets keep counting deleted prints.

  Frontend:
  - ConfirmModal gained an optional children slot.
  - ArchivesPage (both card and detail views) own a per-instance
    deletePurgeStats boolean and render an opt-in checkbox in the delete
    dialog; resets to off on every close so the destructive option is
    never sticky.
  - api.deleteArchive(id, purgeStats?) appends ?purge_stats=true only
    when the box is ticked.
  - One new i18n key archives.modal.deletePurgeStats added across all 8
    locales (full German, English fallbacks elsewhere).
2026-05-15 09:21:43 +02:00
Sn0rrii 8a7598f6b5 feat(auth): proxy OIDC provider icons server-side (#1333) (#1342)
* feat(auth): proxy OIDC provider icons server-side (#1333)

Strict img-src CSP blocked external OIDC icon hosts on the login page.
Loosening CSP was rejected via the MakerWorld precedent, so icons are
proxied: admin sets icon_url, backend fetches and caches the bytes in a
deferred BLOB column, the SPA renders from a same-origin
/api/v1/auth/oidc/providers/{id}/icon endpoint.
2026-05-15 08:50:37 +02:00
maziggy af52c4f2ff fix(spoolman): allow AMS-HT ams_id range in slot-assignment table (#1274)
H2C / H2D AMS-HT units report ams_id 128+ (one ams_id per unit, single
  tray), but spoolman_slot_assignments.ck_ams_id_range only admitted 0-7
  and 255. Every attempt to link a Spoolman spool to an AMS-HT slot died
  with `CHECK constraint failed: ck_ams_id_range`. The internal
  spool_assignment table has no such constraint and works fine.

  Widen the formula to (0-7) OR (128-191) OR 255 in the model, the
  CREATE TABLE DDL, and an idempotent in-place migration for existing
  installs (Postgres: DROP/ADD CONSTRAINT; SQLite: detect stale formula
  in sqlite_master, rebuild via _v2 rename pattern).
2026-05-12 08:48:30 +02:00
maziggy 79d54a8d53 feat(archives): build-plate icon on cards + uniform printer/model line (#1253)
Show an OrcaSlicer-style bed icon in the archive card's printer-name row
  indicating which build plate the print was sliced for (Cool /
  Cool SuperTack / Engineering / High Temp / Textured PEI / Smooth PEI),
  with the full plate name in the hover tooltip. Closes the gap where
  users had to remember which plate matched a re-print or open the
  source 3MF in a slicer just to read the bed setting.

  Card row also unified: archives with a real Bambuddy-printer
  association used to render "H2D-1 GCODE ..." while slicer-only uploads
  rendered "Sliced for X1C GCODE ..." -- same line, two different shapes.
  Drop the "Sliced for " prefix so both render as a uniform
  "<name-or-model> [bed-icon] GCODE <hash>" row, scanning identically
  regardless of provenance.

  Backend: new bed_type column on print_archives (idempotent ALTER TABLE
  migration; SQLite + Postgres safe). Populated from curr_bed_type in
  Metadata/slice_info.config (per-plate, authoritative -- that's what
  got sent to the printer for the exported plate) with a fallback to
  project_settings.config for older 3MF shapes. Wired through both
  archive_to_response() (the hand-rolled dict converter that bypasses
  from_attributes -- easy to miss) and the /rescan endpoint, so old
  archives can be re-parsed via the existing per-archive Rescan button.

  Backfill script (scripts/backfill_archive_bed_type.py, --dry-run
  supported) re-opens every NULL archive's 3MF on disk to populate the
  column. Auto-loads .env from project root before importing backend
  modules (config.py reads DATABASE_URL from os.environ at import time,
  not from pydantic-settings at Settings() time) and prints the resolved
  DB URL with credentials redacted, so operators can confirm they're
  hitting the intended database -- Postgres or SQLite.

  Frontend: 6 OrcaSlicer-style PNGs ship in frontend/public/img/bed/ --
  under /img/ because that path is already statically mounted; a
  toplevel /bed-icons/ tried first hit the SPA catch-all and returned
  index.html as text/html. New utils/bedType.ts maps slicer strings
  case-insensitively, covering both Bambu Studio and OrcaSlicer naming
  variants for the same physical plate. Unmapped or NULL bed_type
  simply omits the icon, so cards stay clean for pre-feature archives.
2026-05-10 09:54:10 +02:00
maziggy f5ecc61cda fix(spoolbuddy): lower /update permission to INVENTORY_UPDATE so kiosk's own Settings -> Update button works
The kiosk's Settings -> Update Daemon button returned "API keys cannot
  be used for administrative operations" because POST /spoolbuddy/devices/
  {id}/update was gated on Permission.SETTINGS_UPDATE, and SETTINGS_UPDATE
  is in the _APIKEY_DENIED_PERMISSIONS deny-list introduced by PR #1241.
  Every kiosk-side request tripped the deny-list before the API key's
  scope set (Read / Print Queue / Control / Legacy) was even consulted.

  Same root cause as the four QuickMenu System buttons fixed in 0.2.4b3
  (Restart Daemon / Restart Browser / Reboot / Shutdown). Missed /update
  in that audit on the reasoning "replaces the daemon binary, different
  threat surface" — but that's wrong: restart_daemon already replaces
  the running daemon process, so daemon-replacement is not a step up in
  blast radius. The SSH update is also strictly scoped to the one device
  the operator physically controls (git fetch + pip install + systemctl
  restart on that host) — same threat profile as the system commands
  already running on INVENTORY_UPDATE.

  Lower /spoolbuddy/devices/{id}/update from SETTINGS_UPDATE to
  INVENTORY_UPDATE so it aligns with the rest of the kiosk-scoped routes
  (calibration/tare, display, cancel-write, system/command,
  system/command-result, update-status). The main Bambuddy in-app updater
  at POST /api/v1/updates/apply keeps SETTINGS_UPDATE — that one runs on
  the Bambuddy host and is correctly fenced behind the deny-list.
2026-05-08 14:28:41 +02:00
MartinNYHC b30a283184 Feature/spoolman inventory UI (#1241)
feat(spoolman-inventory): squashed feature work for rebase onto dev

Squashed all commits from feature/spoolman-inventory-ui onto a single commit
to enable a clean rebase onto dev. Original per-commit history preserved at
backup tag backup/spoolman-inventory-ui-prerebase-20260507-105721.
2026-05-08 11:52:42 +02:00
MartinNYHC dac2a31192 Revert "feat(inventory): unified Spoolman inventory UI + AMS slot assignments…" (#1232)
This reverts commit 55d71498e9.
2026-05-07 11:30:31 +02:00
Sn0rrii 55d71498e9 feat(inventory): unified Spoolman inventory UI + AMS slot assignments + Storage Location + NFC write support + Spoolman Filament Catalog Picker (#1114)
feat(spoolman-inventory): squashed feature work for rebase onto dev

Squashed all commits from feature/spoolman-inventory-ui onto a single commit
to enable a clean rebase onto dev. Original per-commit history preserved at
backup tag backup/spoolman-inventory-ui-prerebase-20260507-105721.
2026-05-07 11:15:24 +02:00
Keybored 37c9d5f26d [Feature] Add Stock forecasting and Logistics view (#1184) 2026-05-05 16:23:42 +02:00
BurntOutHylian e45f967616 feat(backup): Extend Backup to other Git providers (#1160) 2026-05-03 08:12:36 +02:00