Commit Graph
924 Commits
Author SHA1 Message Date
maziggy bb3e2a710e Support non-0.4mm nozzles in AMS Slot config + guard dispatch (#1899)
The Configure AMS Slot picker was hardwired to 0.4mm (nozzleDiameter
prop never passed from PrintersPage / SpoolBuddyAmsPage), so a 0.6
machine could only set 0.4 profiles on its trays. Resolve the real
installed nozzle per-AMS (ams_extruder_map on dual-nozzle) and pass it
in. Separately, nothing validated the sliced nozzle against the
installed one, so a mismatch reached the printer as a cryptic HMS
_8012 "Failed to get AMS mapping table". Add a fail-safe pre-dispatch
guard in _start_print that fails the item with an actionable message
before upload; no slice diameter or no reported nozzles = no-op.
2026-07-08 08:57:56 +02:00
maziggy e06677b795 Redirect authenticated visitors off /login (#1889)
LoginPage rendered the credentials form for an already-authenticated
session, so a direct visit to /login (browsers autocomplete the origin
to it) looked like "Remember Me" never worked despite a live token.
Read user/loading from the auth context and redirect to / once the
auth check settles, gated on the credentials step so the 2FA and
OIDC-callback branches keep their own navigation.
2026-07-08 07:49:13 +02:00
maziggy 917bfd7666 feat(labels): scannable QR on 203 dpi thermal printers + monochrome mode (#1870)
The 40x30 mm box label rendered its QR too densely for low-res thermal
printers — the modules bled together and wouldn't scan. Two causes: the QR
was 20% of inner width (~7.5 mm on the narrowest template, half of the
others) and used ERROR_CORRECT_M. Fix adaptively so all templates benefit:
give the roomy-layout QR a 12 mm minimum size (box_40x30 -> 12 mm, ~3.5
dots/module at 203 dpi) and switch label QRs to ERROR_CORRECT_L (same
payload, chunkier modules; a label needs no M-level recovery). Keep the
quiet-zone border at 2 — the size+L gains suffice without risking scans.

Also add a Monochrome (black & white printer) option to the label dialog:
drops the colour swatch (a useless grey block on B&W) and widens the text;
the hex-code line still carries the colour. Threaded through the renderer,
route, API client, and modal, with translations in all 11 locales.
2026-07-07 10:31:19 +02:00
maziggy 1d344a8536 fix(ui): resolve light-theme low-contrast semantic text app-wide (#1909)
The app was built dark-first, so hundreds of hardcoded Tailwind semantic
text/icon utilities at light shades (text-amber-400, text-blue-300, ...) had
no dark: variant. With darkMode:'class' they applied in light theme too,
producing washed-out text on pale tints and white cards — including the three
reported spots (AMS Drying banner, Archives no-3MF warning, debug-logging
banner). Give each a theme-aware pair: a darker readable shade in light theme
with the original pinned to dark:, so dark theme is unchanged. ~100 files.

The bambu-* CSS-variable palette (self-correcting) and the dark-only SpoolBuddy
kiosk are left untouched. Plain text-white is already theme-aware via the
existing index.css .text-white override, so it needed no changes.
2026-07-07 09:54:10 +02:00
maziggy 4af9da26f9 fix(sponsor): anchor 14-day toast cooldown on show, not just on CTA click (#2477)
The sponsor toast re-fired on every fresh browser session. The backend
owns the 14-day cooldown but only persists the anchor (last_shown_at) and
the seen-milestone record inside POST /sponsor-prompt/dismiss, and the hook
only called dismiss from the "View supporters" CTA onClick. A user who saw
the toast but never clicked the CTA persisted no state; the per-tab
sessionStorage guard hid the re-fire within one session, but every new
session re-checked against empty state and re-showed the same milestone.

Record the toast as shown the moment it renders (POST /dismiss right after
showPersistentToast) so display is what arms the cooldown. CTA click stays
optional and just navigates. Frontend-only; backend cooldown logic unchanged.
2026-07-07 08:30:58 +02:00
maziggy a82eeff483 fix(ui): restore missing per-user Notifications nav item (#1901)
The sidebar-ordering refactor in #1673 accidentally dropped the
`notifications` entry from `defaultNavItems` and its
`notifications:user_email` permission mapping, but kept the advanced-auth
visibility gate that references that id. With no nav entry the id never
enters the render set, so the /notifications page (route, page, and API
all intact) became reachable only by typing the URL — users could no
longer opt in/out of their own print email notifications from the menu.

Restore both the defaultNavItems entry and the permission gate, matching
the permission the user-email-preferences API actually requires
(notifications:user_email, held by both default groups). Add comments so
the entry isn't dropped again in a future sidebar refactor.
2026-07-06 07:38:44 +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 e9cddc544a fix(websocket): stop the ws-token reconnect loop on auth failure
After the GHSA-r2qv gate (b7d7c825), /api/v1/ws needs a token from
POST /api/v1/auth/ws-token (Permission.WEBSOCKET_CONNECT). When the mint
failed, useWebSocket swallowed the error, opened a tokenless socket, the
server closed it 4401, and ws.onclose rescheduled connect() every 3s -
an endless loop that hammered /auth/ws-token. The dominant trigger is a
validly-logged-in user whose group lacks WEBSOCKET_CONNECT (mint returns
403). A secondary leak: the unmount-triggered onclose could schedule a
post-unmount reconnect.

Classify the mint failure: 401 (JWT expired; request() already clears it
and dispatches auth:expired) or 403 (valid session, missing permission;
degrade to REST polling) now stop the hook - no tokenless socket, no
reconnect. A 4401 close is terminal. Network/5xx still reconnect. A
disposedRef set in cleanup before close() prevents the unmount-race
reconnect. Same 401/403 no-open guard applied to StreamOverlayPage.

Also surface a one-line hint under the WebSocket permission in the group
editor (all 11 locales) explaining that live updates need it and fall
back to polling without it - rather than auto-granting the permission,
which would partly undo the GHSA-r2qv gate.
2026-07-05 09:12:42 +02:00
maziggy 646a8b13fd fix(auth): don't discard a valid stored token on a transient load-time error (#1889)
On mount, AuthContext.checkAuthStatus restores the persisted "Remember Me"
token from localStorage and validates it via GET /auth/me. The catch around
that call cleared the token on ANY failure, not just a definitive 401
invalid-token — so a brief backend-not-ready or reverse-proxy hiccup during
page load (plausible right after a container restart, e.g. on Unraid) would
delete a still-valid token. Because the token was deleted, a reload couldn't
recover it and the user was bounced to the login screen.

Token validation now retries transient failures (up to 3 attempts with short
backoff) and only discards the token on a definitive 401 — which request()
already handles (clears the token and dispatches auth:expired). Transient /
5xx / network errors leave the persisted token intact so the session survives
a slow load. "Remember Me" stays client-storage only; it does not extend the
server-side JWT lifetime (session_max_hours, default 24h).

Adds AuthContext tests: transient /auth/me failure keeps the token, a
definitive 401 clears it, and a valid token loads the user. Rebuilt frontend
bundle.
2026-07-03 08:57:20 +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 10ad9267e1 fix(queue): edit modal shows printer/model selection for model-assigned items
Editing a queue item that was created with "Any of model X" left the
printer selection area completely blank — the assignmentMode was
initialised to 'model' from queueItem.target_model, but the three
model-mode props (onAssignmentModeChange, onTargetModelChange,
onTargetLocationChange) were gated behind !isEditing. That flipped
modelAssignmentAvailable to false in PrinterSelector and hid the mode
toggle, the model dropdown, AND the location filter; combined with the
assignmentMode === 'printer' gate on the printer list, the whole
selector rendered empty.

Users hit this whenever they queued something to "Any of model X" and
then wanted to change the target model / location — the only workaround
was delete + re-queue.

Fix: drop the !isEditing gate on all three PrinterSelector props. The
submit path already handles both flavours (target_model+target_location
with printer_id=null vs. printer_id with the target fields nulled), so
un-gating the UI just surfaces the machinery that was already there.
Edit is still only offered on pending items, so the mode-flip can't race
an in-flight dispatch.
2026-07-02 09:39:56 +02:00
maziggy ed510cb9bc feat(currency): add Indonesian Rupiah (IDR) support (#1869)
Adds IDR with Rp symbol to the supported currencies list, available
in Settings → Cost Tracking.
2026-07-01 11:04:50 +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 b71d486058 fix(printers): drop P1S / P1P from door-sensor badge whitelist (#1866)
P1S has an enclosure door but no hall sensor for it; P1P has no
enclosure at all. Both models were rendering a permanent green
"Door Closed" chip driven by bit 23 of the stat field, which stays
0 forever on that firmware. Whitelist now covers only models that
actually ship with a door sensor: X1 family, X2D, P2S, and H2 family.
Corrected the matching stale comments in the PrinterStatus TS
interface (client.ts) and PrinterState dataclass (bambu_mqtt.py).

Backend parse left as-is — cheap and future-proof if Bambu ever
wires the P-series enclosure into a sensor.
2026-07-01 08:58:22 +02:00
maziggy 14665f4281 fix(modal): gcode_injection checkbox toggles cleanly on single prints (#1852)
PrintModal carried a useEffect that reset scheduleOptions.gcodeInjection
to false whenever mode === 'create' AND effectiveQuantity <= 1. The
comment claimed the checkbox only renders for quantity > 1, but the
actual render gate in ScheduleOptions is just hasGcodeSnippets — no
quantity check. So with snippets configured + quantity = 1 (the OP
scenario): user clicks the checkbox, React updates state to true,
the parent's useEffect immediately sees effectiveQuantity <= 1 and
resets to false, and the checkbox appears un-clickable. Edit-queue-
item mode worked because mode !== 'create' short-circuited the reset.

Drop the effectiveQuantity <= 1 clause from the reset. Keep the
!settings?.gcode_snippets half as the legitimate cleanup for the
"admin removes all snippets while modal is open" case. The scheduler
reads item.gcode_injection per queue item regardless of batch size,
so single prints can inject too.
2026-06-29 12:44:08 +02:00
maziggy 61a7f2e4ac feat(scheduler): preheat & heat-soak before queued prints with per-filament chamber targets + airduct flap control (#1468)
New scheduler stage that heats the bed (and the chamber, on supported
printers) and holds at temperature before each queued print starts —
the heat-soak engineering filaments need for adhesion and warp
control. Bambuddy waits between FTP upload and start_print, so the
soak runs while the printer is otherwise idle. M191 is silently
ignored by Bambu firmware, so doing this at the orchestration layer
is the only place it works.

Resolution order at dispatch:

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

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

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

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

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

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

Best-effort throughout: printer drops, refused M141 or set_airduct,
missing bed temp, lost MQTT state mid-wait all log and return cleanly.
Normal upload + start path runs after this returns regardless.
2026-06-29 12:35:43 +02:00
maziggy a45d32efd0 fix(hms): wrong-plate Ignore actually ignores + buttons read as buttons + ack-detection survives transient re-pause (#1869)
The HMS error modal had three compounding bugs that surfaced when a
user forced a wrong-plate HMS (0500_8051) and tried to dispatch the
per-fault actions.

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

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

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

Fixes:

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

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

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

(1) Mine `[error] <msg>` (with or without `run NNNN:`) from the full
pre-trim response; substitute the placeholder, keep meaningful
headlines. (2) Partition candidates into compatible/unknown vs
mismatch; prefer compatible whenever the bucket is non-empty, fall
back to mismatch only on graceful-degrade. Picker helpers moved out
of `SliceModal.tsx` into `utils/slicePresetPicker.ts` so the modal
file stays component-only (react-refresh lint).
2026-06-29 08:23:29 +02:00
maziggy 98b4d1c854 fix(frontend/hms): surface uncataloged HMS faults that carry firmware actions (#1840)
filterKnownHMSErrors and the modal-local copy gated visibility on
ERROR_DESCRIPTIONS membership. H2C 0500_809C carries IGNORE_RESUME /
PROBLEM_SOLVED_RESUME but is missing from the bundled 853-entry catalog,
so the entire error — pip, count, panel, action buttons — never rendered
even though backend captured + dispatched it correctly.

The gate isn't dead code: PrintersPageBucketing pins the post-cancel
0C00_001B junk-echo regression to it. Widen the predicate to keep
(cataloged) OR (actions.length > 0) so noise is still filtered out
while user-actionable faults always surface.

Replace the modal's inline filter with the shared helper so badge
counts and modal contents agree by construction. Fall back to
hmsErrors.unknownCode ("Unknown HMS code — see the Bambu Lab wiki
for details.") when the catalog has no entry. New key translated in
all 11 locales.

New bucketing test pins PAUSE + uncataloged-with-actions = error;
existing FAILED + uncataloged-without-actions = finished stays green.
2026-06-28 12:02:14 +02:00
maziggy b23cb69a66 fix(permissions): self-heal Administrators to ALL_PERMISSIONS on upgrade + Pipelines runs dashboard polish
Administrators system group sync
- Fresh installs already bootstrap with ALL_PERMISSIONS, so they always have
  every permission. Upgrades previously only got what one-off backfill blocks
  in seed_default_groups() explicitly listed (library:purge, archives:purge,
  the OWN/ALL read-flag block, orca_cloud:auth, pipelines:*). Any Permission
  enum member added without a matching block silently stayed missing on
  existing admin rows. The most recent gap was printer_sensor_history:read
  (Sensor History charts returned 403 for upgraded admins).
- seed_default_groups() now syncs Administrators to ALL_PERMISSIONS on every
  startup: append every Permission value that isn't already on the row.
  Additive only -- hand-added custom permissions are preserved.
- The pure-admin one-off backfills (library:purge / archives:purge block,
  the OWN/ALL + orca_cloud:auth + legacy-read-flag block, the Administrators
  branch of the pipeline backfill) are retired since the sync subsumes
  them. Non-admin backfills (Operators / Viewers OWN-tier reads, Operators
  orca_cloud:auth, pipelines for non-admin groups, makerworld:*, clear_plate
  cross-group adders) are untouched.
- Tests: test_administrators_printer_sensor_history_read_backfilled
  (regression for the reported gap),
  test_administrators_sync_covers_every_current_permission (generic
  invariant -- any future new permission lands on admin without needing
  a one-off test), test_administrators_sync_is_additive_only (custom
  permissions preserved). 12/12 backfill-migration + 102/102 broader
  permission tests green; ruff clean.

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

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

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

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

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

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

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

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

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

Frontend
- Settings → Workflow splits into two horizontal sub-tabs mirroring
  the Authentication tab pattern: "Queue & Dispatch" (existing
  Workflow content) and "Pipelines" (new). URL deep-link via
  ?tab=queue&sub=pipelines.
- SlicerPipelinesPanel — list, inline rename, delete, stale-preset
  warning when a referenced preset no longer resolves.
- SliceModal gets "Apply pipeline ▾" + "Save as pipeline". Apply
  fills all four slot states; the filament list right-pads from
  current state so a pipeline with fewer entries than the current
  source's slot count keeps the existing tail.
2026-06-27 13:56:18 +02:00
maziggy 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 91e4219994 fix(inventory): assign-spool picker note visible on mobile (#793 follow-up)
The original #793 fix added the spool note as an HTML title= tooltip
on each picker button in AssignSpoolModal.tsx. title= only surfaces
on hover, which doesn't exist on touch devices — phone users tapping
a card just selected it, the note never appeared. Users who store
their tracking ID in the note field were blind on mobile (raised by
@EmcetPL on the closed issue).

Render the note as a small muted truncated line directly under the
weight on both the internal-inventory branch (line 436-ish) and the
Spoolman branch (line 510-ish): text-[10px] text-bambu-gray/70 mt-1
truncate, kept inside the truthy `&&` guard so empty notes don't add
a blank row. The existing title={spool.note} is preserved on the new
<p> so desktop hover and mobile long-press still surface the full
untruncated text for notes that overflow the truncate.

Mirrored across both inventory branches per the parity rule
(internal and Spoolman pickers stay shape-equal). No backend change,
no new state, no popover, no new touch target.
2026-06-27 09:56:15 +02:00
maziggy d4ad41d850 fix(hms): action buttons actually reach the printer (#1830)
Three distinct bugs combined into one user-facing failure: clicking
Stop / Problem-solved-and-resume / Ignore-and-resume returned 200 OK
but the printer didn't act, modal stayed up, print stayed paused.
Verified by injecting candidate command shapes on device/<sn>/request
against a live H2D paused on a wrong-plate HMS (print_error=0x05008051).

(1) hms_resume / hms_stop dispatched the "err"-bearing shape that
BambuStudio doesn't actually send; Bambu firmware silently rejects it.
Both now send the plain shape ({"print":{"command":"<x>","param":"",
"sequence_id":"0"}}). PAUSE -> FAILED in 1.7s for stop, PAUSE -> RUNNING
in <2s for resume.

(2) IGNORE_RESUME mapped to idle_ignore, which is BambuStudio's
"dismiss a warning" command and only works for non-pause warnings.
hms_ignore now branches on state.state == "PAUSE": paused -> plain
resume; not-paused -> idle_ignore with the full-length err.

(3) 64-bit hms[]-array faults were truncated to a non-matching err.
short_code in _parse_status discarded 32 of the 64 identifier bits, so
the firmware didn't match it to the active fault. HMSError.full_code
now carries the canonical hex identifier (16 chars for hms[] faults,
8 chars for print_error faults). Catalog lookup tries 16-char first,
falls back to 8-char. HmsActionBody.print_error pattern relaxed to
^[0-9A-Fa-f]{8}([0-9A-Fa-f]{8})?$.

(4) execute_hms_action returned publish-success as success, masking
every silent-rejection bug above as 200 OK. Route now snapshots
(state.state, len(state.hms_errors)) before dispatch, awaits
HMS_ACTION_ACK_WAIT_SECONDS (default 2.5s, module-level so tests
override), and returns 502 with "Printer did not acknowledge HMS
action within 2.5s" if state didn't move.
2026-06-27 09:18:57 +02:00
maziggy 3cb0433569 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-26 16:10:49 +02:00
maziggy 510005f043 fix(printers): cam wall — offline tile chip + don't kill shared
streams when one viewer closes

1) Offline tiles now show OFF (not LIVE)
   CameraWall.modeByPrinter assigned 'live' to any visible printer
   without considering status.connected, so a disconnected X1C wasted
   a live-budget slot AND rendered the red LIVE chip on top of the
   WifiOff placeholder. Disconnected printers now map to 'paused' and
   don't decrement liveBudget — the existing WifiOff + Off chip
   rendering takes over.

2) /camera/stop no longer kills other viewers' streams
   The cam-wall tile, EmbeddedCameraViewer, and the /camera/:id popup
   all subscribe to the same fan-out broadcaster for a printer.
   /camera/stop used to unconditionally shutdown_broadcaster() + kill
   every ffmpeg process for the printer, so closing the embedded viewer
   while the cam-wall tile of the same printer was live force-killed
   the source the tile was pulling from — the tile's <img> errored.

   New get_subscriber_count(key) accessor in camera_fanout.py exposes
   the broadcaster's subscriber list length. /camera/stop now reads
   that first; when >= 1 subscriber is still attached, return
   {stopped: 0, skipped: true} and leave the broadcaster + ffmpeg
   processes alone. The leaving viewer's HTTP teardown still runs the
   natural iter_subscriber.finally -> unsubscribe path, so its slot is
   released; the broadcaster keeps serving the other viewers. Single-
   viewer close still hits the immediate force-teardown (count is 0).
2026-06-26 16:01:28 +02:00
maziggy 6f727d300a Post work PR #1743 2026-06-26 14:59:29 +02:00
Zelda 3ddf8d847e [Feature]: HMS Actions (#1743) 2026-06-26 14:40:25 +02:00
maziggy 1c683f063c fix(queue): ownership gates + TOCTOU lock + /reorder validator (#1625-followup)
Three issues from the post-merge audit of the unified-dispatch PR, all
pre-existed on dev but became more impactful once every print routes
through the queue:

1. Start/Stop ownership gates. /queue/{id}/stop required QUEUE_UPDATE_ALL
   (admin-only) -- operators saw the Stop button in the queue UI but got
   403 on click. /queue/{id}/start required QUEUE_UPDATE_OWN with no
   ownership check -- _OWN holders could start anyone's queue items via
   direct API. Both routes now use require_ownership_permission, mirroring
   /cancel. Stop is strict (rejects unowned items for _OWN); start preserves
   #1670's VP-import flow where _OWN can start NULL-owner items and claim
   ownership at click-time. Frontend QueuePage Start/Stop buttons flip
   from printers:control to canModify('queue', 'update', created_by_id).

2. TOCTOU race on insert_position. Concurrent ASAP inserts to the same
   scope both computed MAX(position) from before the other committed; in
   an empty scope, both inserted at position=1 (duplicate). Wraps the
   read+update in a transaction-scoped Postgres pg_advisory_xact_lock
   keyed on the printer_id. Different printers don't contend. SQLite
   serializes writes implicitly so the path is no-op there. Dialect is
   checked against the live session binding, not the is_sqlite() helper,
   because the test fixture overrides get_db to SQLite while
   settings.database_url still points at Postgres.

3. /reorder duplicate-position validator. POST /queue/reorder set position
   from the payload in a loop with no uniqueness validation -- a buggy
   drag-drop client could leave the queue with ambiguous ordering (the
   scheduler's ORDER BY (printer_id, position) ties break by row order).
   New model_validator on PrintQueueReorder rejects duplicates at the
   schema layer with 422 + "Duplicate positions in reorder request: [N, ...]".
2026-06-26 13:06:40 +02:00
Ed 4c67d8a4e1 feat: Unify print dispatch through the scheduler (#1625) 2026-06-26 12:31:48 +02:00
maziggy 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 b90dee02ab feat(printers): cam-wall view with on-screen live cap and snapshot fallback (issue #451)
New view toggle on the Printers page renders a responsive grid of live
  camera tiles instead of printer cards. Reuses the existing /camera/stream
  fan-out so the backend ffmpeg pipeline is unchanged.

  To stay sustainable on the median Pi 4 install, only on-screen tiles run
  live, and only up to a per-user cap (default 4). Other visible tiles
  fall back to periodic /camera/snapshot polling (default 8s). Off-screen
  tiles pause entirely. Tiles POST /camera/stop on unmount and on
  leave-live so the backend transcoder slot is released the same way
  EmbeddedCameraViewer does it.

  CameraTile is a 3-mode leaf (live / snapshot / paused) with a single
  <img> and an onError no-signal fallback. CameraWall is the scheduler:
  IntersectionObserver tracks visibility, a stable walker over the sorted
  printer list assigns live slots first-N-visible to avoid LRU churn. Same
  ['printerStatus', id] React Query cache the cards already populate, so
  flipping between Cards and Cam Wall is instant.

  Tile click honours the existing Settings camera_view_mode preference
  (window vs embedded). Both wall settings are per-user localStorage
  (camWallMaxLive, camWallSnapshotSec) — a Pi 4 user and a NUC user want
  different caps.
2026-06-25 13:58:34 +02:00
maziggy fd61812d01 feat(drying): show active-cycle filament + target temperature on the AMS drying badge
Bambu's per-tick AMS push carries only the dry_time countdown — the
  filament name and target temperature the user chose are never echoed on
  the wire. The AMS card had no source of truth for them and rendered the
  bare "Drying · 11h 35m left". The badge now shows
  "Drying · PETG @ 65°C · 11h 35m left", matching the cycle the user
  actually started.

  BambuMQTTClient caches {ams_id: {filament, temp}} on send_drying_command
  (mode=1), clears on mode=0 and on the dry_time falling edge to 0 — the
  same per-AMS edge detector that drives the smart-plug-after-drying
  callback. PrinterManager.get_drying_targets exposes it, the four
  printer_state_to_dict call sites thread it through, AMS schema gains
  dry_target_temp + dry_filament, and routes/printers.py builds the same
  fields into the manually-constructed AMSUnit response.

  When no cached target exists (drying started in a previous backend
  lifetime, or initiated outside Bambuddy), the badge falls back to the
  first loaded tray's tray_type + RFID-recommended drying_temp — the
  heuristic the popover already uses to seed defaults.

  i18n: printers.drying.targetSummary = "{{filament}} @ {{temp}}°C" in
  all 11 locales. Parity check 5356 leaves per locale.

  Note: a user reported the H2D's own physical display still labels the
  cycle by the loaded tray's filament (e.g. "PLA" instead of the
  Bambuddy-requested "PETG"). The wire payload is correct end-to-end —
  journalctl shows filament: "PETG" sent and result: success ACKed — and
  the badge in Bambuddy's own UI now reflects what we actually sent,
  independent of the firmware's display choice.
2026-06-25 13:27:32 +02:00
maziggy 8d6f701f1d feat(drying): continue drying while printing + gate rotate-spool when tray loaded (issue #1816)
Continue Auto-Drying while a print is running on capable hardware.
  New Settings > Print Queue > "Continue drying while printing" toggle
  (default OFF). Extends _check_auto_drying in print_scheduler.py to
  evaluate running printers when supports_drying_while_printing(model,
  firmware) returns true. Strict allowlist verified per Bambu wiki
  release notes for "Print While Drying" / "printing while filament is
  drying": H2D 01.03.00.00+, H2C/H2S/P2S/H2D Pro 01.02.00.00+, X2D/A2L
  01.01.00.00+, X1C 01.11.02.00+. P1*, A1, A1 Mini, X1 (non-C), X1E
  intentionally excluded. Mid-print drying temperature is capped at
  max(40, preset_temp - 5) to protect spools from heat damage inside the
  hot enclosure during a print, matching Bambu's own "lower drying
  temperature during printing" guidance.

  Rotate-spool toggle in the drying popover is now disabled when any tray
  in the targeted AMS has filament threaded into the feed tube
  (tray.state === 11). The whole AMS rotates as one mechanism, so a
  single loaded slot locks the entire unit. Previously the toggle was
  always clickable and the firmware rejected with dry_sf_reason=[3]
  (ConsumableAtAmsOutlet) after the click. The first cut keyed on the
  printer-level tray_now but missed the H2D's typical post-print state
  where tray_now resets to 255 while filament stays in the tube — the
  per-tray state field reports it correctly. Submission also clamps
  rotateTray off so a stale-true state from a previous AMS can't leak
  through.

  Backend: supports_drying_while_printing in printer_manager.py covers
  display names and internal SSDP/MQTT codes (O1D, O1E/O2D, O1C/O1C2,
  O1S, N6, BL-P001, N7, N9). New print_drying_enabled boolean in
  settings schema. Frontend: toggle on SettingsPage, gate + clamp on
  PrintersPage drying popover using existing amsData cache. i18n: 3 new
  keys x 11 locales, no English fallback. Tests: 7 cases on the gate
  matrix (TestSupportsDryingWhilePrinting), 4 cases on the scheduler
  mid-print path (TestMidPrintDrying), 9 cases on the rotate gate state
  transitions. Full backend pytest -n 30 green (4251/4251), ruff clean,
  frontend npm run build clean, i18n parity 5355 leaves per locale.
2026-06-25 12:47:26 +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
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 5c9c49c35b fix(archives): correct #1812 — Step 4 wiki link host
Banner pointed to bambuddy.cool/wiki/getting-started/... which 404s;
  the wiki lives under the wiki.bambuddy.cool subdomain. Anchor was
  correct (MkDocs slug matches the existing "Step 4: Enable Store sent
  files on external storage" heading).
2026-06-25 08:30:50 +02:00
maziggy fb3821630f feat(inventory): batch / mass edit on the Filament tab (#1795)
Bulk operations on the Inventory page in both built-in and Spoolman modes.
  Reporter wanted ten-of-the-same-spool edits without ten round-trips through
  the per-spool editor.

  Frontend
  - New checkbox column on the inventory table (header / row / group). Sticky
    toolbar appears when at least one row is selected with Edit / Print labels /
    Reset usage / Archive (or Restore in the Archived tab) / Delete / Clear.
    Selection clears on any filter / tab / search change so the count can't
    drift from what is on screen.
  - BulkEditSpoolsModal is a three-state-per-field form. The user opts in per
    field by ticking its checkbox or just typing into it; only ticked + non-
    empty fields are sent. Clearing fields in bulk is intentionally NOT
    supported per the issue discussion.
  - A new SearchableSelect renders all categorical fields (material, sub-type,
    brand, category, slicer preset name, slicer filament, storage location)
    with the same dropdown pattern the per-spool editor uses - text input +
    chevron + filtered button list, click-outside / Escape closes. No native
    select anywhere in the modal. Options merge the canonical constants from
    spool-form/constants.ts with whatever already exists in the user's
    inventory. Slicer-preset dropdowns fetch the same sources as the per-spool
    form (Bambu Cloud + Orca Cloud + local + built-in) through buildFilament
    Options() and three useQuery calls gated on isOpen.
  - onSuccess handlers surface three outcomes: all-succeeded (green toast),
    partial-success (yellow toast with ok / failed counts), all-failed (red
    toast that keeps the selection and modal open so the user can retry).
    The first cut silently dropped errors / not_found arrays - audited and
    fixed before merge.
  - Invalid rgba hex is flagged inline with a red border + helper text and
    the Apply button is gated on a hasDroppedTickedField guard, so silently
    dropping a ticked field is no longer possible.
  - bulkResetConsumedCounterMutation.onSuccess now closes the confirm modal +
    clears selection, matching the other three bulk mutations.

  Backend
  - Four new endpoints per inventory mode (eight total):
      POST /api/v1/inventory/spools/bulk-update         INVENTORY_UPDATE
      POST /api/v1/inventory/spools/bulk-delete         INVENTORY_UPDATE
      POST /api/v1/inventory/spools/bulk-archive        INVENTORY_UPDATE
      POST /api/v1/inventory/spools/bulk-restore        INVENTORY_UPDATE
      POST /api/v1/spoolman/inventory/spools/bulk-*     FILAMENTS_UPDATE
  - Built-in update runs the same prepare_internal_spool_payload(...) +
    weight_used / weight_locked auto-stamp as the per-spool PATCH.
  - Spoolman update loops the per-spool update_spool route function so the
    filament re-linking / extra-dict / extra-lock / shared-filament rules
    stay byte-identical to single-spool edits.
  - Per-spool failures inside the batch are collected. Spoolman bulk-delete /
    archive / restore now catch non-HTTPException too (matches bulk-update) -
    a mid-batch httpx.ConnectError or TimeoutError no longer aborts the route
    with a 500 and skips the WS broadcast.
  - Both modes broadcast a single inventory_changed WS event at the end of
    the batch.
2026-06-23 11:34:15 +02:00
maziggy 7cb905ad0c feat(inventory): toggle to disable auto-add of unknown RFID spools + global confirmation modal (issue #1764)
New setting "Auto-add unknown RFID spools" under Settings -> Filament -> Filament Tracking,
  default ON for back-compat. When turned off, the backend stops auto-creating an inventory
  record for an unknown RFID tag and instead broadcasts an unknown_tag WS event that pops
  a global confirmation modal in the Bambuddy UI showing the printer / AMS-X label / slot /
  material / colour. Add or Cancel; no nag on every MQTT push.

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

  Frontend
  - useUnknownTagPrompt hook listens for the unknown-tag CustomEvent, reads the tray fields
    out of the event detail, and feeds a single-modal queue. No long-lived dismissed set;
    the backend dedup handles spam suppression.
  - UnknownSpoolModal wraps the existing ConfirmModal with a material + colour-swatch
    preview block.
  - Mounted in Layout.tsx alongside useSponsorPrompt so SpoolBuddy kiosk / login / setup
    routes are excluded.
  - getAmsLabel moved to utils/amsHelpers.ts; ConfigureAmsSlotModal.tsx and PrintersPage.tsx
    both import the shared version (canonical AMS-A / HT-A / External labels).
  - AppSettings TS interface gained spoolman_enabled, auto_add_unknown_rfid, spoolman_url
    so the runtime cast in the hook is no longer needed.
  - SpoolmanSettings.tsx gets a new toggle row in the Filament Tracking card, visible in
    both built-in and Spoolman branches; auto-save + toast already wired.
2026-06-23 09:59:05 +02:00
maziggy 50a4c4c3eb Post work PR #1798 2026-06-22 14:46:24 +02:00
maziggy f4a4d6dceb feat(system): appliance locale defaults endpoint + frontend i18n bootstrap
Closes the cross-repo contract started in bambuddy-appliance: the firstboot
  wizard writes /etc/bambuddy/local.toml with the user's hostname / timezone /
  locale, but nothing on the main app side read it. Hostname + timezone are
  already applied by the appliance's firstboot.sh via hostnamectl /
  timedatectl. This PR closes the loop for the third field — locale — so the
  language the user picked in the wizard actually shows up on first SPA load.

  backend/app/core/local_config.py

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

  backend/app/api/routes/system.py

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

  frontend/src/i18n/index.ts

  One-shot applyApplianceLocale() runs after i18n.init(). Gated by a
  bambuddy_appliance_locale_consumed localStorage flag so it runs at most
  once per appliance. Fetches /api/v1/system/appliance, validates the
  returned locale against supportedLngs, calls i18n.changeLanguage if
  valid. Silent .catch() because the endpoint absent / unreachable means
  non-appliance install or dev environment -- we leave the LanguageDetector's
  choice in place. The consumed flag is set on success; future loads skip
  the fetch entirely. Won't override a user's explicit language pick (the
  language picker writes to a separate localStorage key, bambutrack_language).
2026-06-22 14:13:44 +02:00
maziggy bb42b423af feat(file-manager): user-authored tags for cross-cutting filtering (#1268)
Third and final piece of #1268, alongside the recursive-search +
  README-panel commit that landed earlier in 0.2.5b1. Folders express
  hierarchy (one home per file); tags are orthogonal labels — "toy",
  "kid-safe", "petg-only" — and a single file can carry as many as the
  user wants. Reporter wanted to find "every toy regardless of which
  folder it lives in"; folders alone can't do that without forcing the
  file into one bucket.

  Design decisions locked with maziggy before code:

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

  Backend

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

  Frontend

    - LibraryTagsModal (catalog CRUD) opens from the toolbar's new
      Tags button. max-w-4xl so multi-language subtitles don't wrap.
      Delete-with-warning when file_count > 0 ("removes the chip from
      all of them; files themselves are untouched").
    - BulkTagsPickerModal opens from the multi-select toolbar (new
      Tag button between Move and Delete). Add/Remove radio,
      checkbox list, inline "create new tag" disabled on dup.
      Apply disabled until at least one tag is selected. The replace
      action is exposed in the API but deliberately NOT in this UI —
      arbitrary multi-file replace is destructive and confusing.
    - FileManagerPage integration:
        * selectedTagIds state, sorted into the useQuery key so the
          cache hits are stable regardless of toggle order
        * filter rail above the file list lists EVERY catalog tag as
          a togglable chip — inactive outlined, active filled green
          with an X. Clear all when 1+ active. Bar hidden entirely
          when catalog is empty.
        * useEffect prunes selectedTagIds when a tag is deleted from
          the catalog so the filter never strands on a phantom id
        * dedicated Tags column in list view at minmax(0,200px)
          between Prints and Actions
        * grid view chips render below the metadata block
        * chip clicks stop propagation so they don't toggle file
          selection
    - libraryTagsQueryKey extracted to frontend/src/utils/
      libraryTagsQuery.ts so component files export only components
      (Vite react-refresh rule).
    - LibraryFileListItem.tags is OPTIONAL even though the backend
      always emits an empty array — legacy msw mocks in pre-existing
      tests construct partial file shapes without the field. Without
      the ? the FileCard renderer crashed on .length and broke 49
      unrelated tests across FileManagerPage + FileManagerExternalFolder.
      Read sites use file.tags ?? [].
2026-06-22 12:27:58 +02:00
maziggy 5cbefca6a0 feat(file-manager): recursive subfolder search + per-folder markdown description panel (#1268)
Reporter (@zumik3-del, seconded by @unLieb) asked for three File Manager
  improvements: recursive search, tags, and a markdown preview side panel.
  This commit ships the two scoped ones; tags is held back gated on the
  "give the issue a thumbs up" interest check Martin posted on the issue
  because it's a much larger surface (M2M schema, CRUD endpoints, tag UI +
  filter + autocomplete + i18n for the management surface) and isn't the
  right call without a real demand signal.

  1) Recursive search inside the selected folder.

     Until now, selecting "Toys" and typing "robot" only found files
     directly in Toys/ — anything under Toys/Cars/Race/ stayed invisible.
     The page's client-side filter ran over a server-narrowed listing
     (/library/files?folder_id=X is strict equality on folder_id), so the
     client filter couldn't see what the listing never loaded.

     list_files (backend/app/api/routes/library.py:1729+) gains a
     recursive=true query param. When combined with folder_id, the route
     walks library_folders.parent_id via a recursive CTE rooted at the
     requested folder and returns every descendant folder's files in one
     query. Recursive CTEs work on both SQLite >=3.8.3 (2014, well below
     Bambuddy's runtime floor) and Postgres without dialect branching.
     Default off so the existing folder-browsing call sites (Project /
     Archive detail, the FE's no-search case) keep their narrow scope.

     FE opts in only when both a folder is selected AND searchQuery is
     non-empty (FileManagerPage.tsx — derived as searchExpandsSubfolders,
     threaded through the useQuery key so the cache invalidates on
     toggle). Small "Including subfolders" caption renders under the
     search input when active so the user understands why a file from two
     levels deep showed up.

  2) Per-folder markdown description panel.

     New endpoint GET /library/folders/{folder_id}/readme returns the
     first .md file in the folder as {filename, content, truncated}.
     Selection prefers README.md / readme.md / description.md
     (case-insensitive via func.lower(filename) LIKE '%.md' + an
     in-Python stem-preference sort), falls back to the
     alphabetically-first *.md otherwise. 404 when no markdown is present
     so the FE can hide the side panel — non-users pay no UI cost.

     Bytes are clipped at 512 KiB (_README_BYTES_CAP) with a truncated
     flag so the panel can warn the reader. UTF-8 decode uses
     errors="replace" so one bad byte never blanks the panel.

     New FolderReadmePanel.tsx fetches on folder-select and renders via
     react-markdown@9 + remark-gfm@4 (tables, strikethrough, task lists).
     Collapsible (default expanded), max-height 24rem with internal
     scroll. react-markdown 9 doesn't render raw HTML by default — no
     dompurify needed. Links open in a new tab with rel=noopener
     noreferrer. Tailwind has no typography plugin in this project so
     per-element components map h1/h2/h3/p/ul/ol/code/blockquote/table
     to explicit utility classes that match the rest of the app.

  Scope and permissions.

  Both endpoints reuse the existing LIBRARY_READ_ALL / LIBRARY_READ_OWN
  ownership-aware pair, so a viewer-tier user with read_own only sees
  their own files in recursive listings and can only fetch the README of
  folders containing their own files. No new permission, no DB migration.

  The recursive CTE is a single SQL query — no N+1, no per-folder
  round-trip, scales to deeply-nested model libraries.
2026-06-22 11:40:58 +02:00
maziggy 9d74f9281b 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-22 10:00:02 +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 31ee7a5d9a feat(file-manager,archives): page-wide drag-and-drop upload (#1510)
Adds page-wide drag-and-drop file upload to the File Manager
  tab — drop any file anywhere on the page and the upload modal
  opens pre-populated with the dropped files. The Upload Files
  button still works for click-to-browse.

  Also fixes the Archives drag-cancel bug @maikolscripts reported
  in the same issue: cancelling a drag (drag back outside the
  browser, Escape mid-drag, or release outside the page) used to
  leave the overlay stuck until page refresh.

  Both pages share a new hook usePageFileDrop. The fix:
  - relatedTarget containment check (catches drag-out-of-window)
  - document-level drop / dragend / keydown(Escape) listeners
    that only register while isDraggingOver === true, so the
    three cancel paths all reset uniformly

  FileUploadModal gains an optional initialFiles prop so the
  File Manager page can pre-seed the modal from a page-wide
  drop. seededInitialRef guards against re-adding on re-renders.

  Permission gate: File Manager drop zone disabled when the
  user lacks library:upload, so a viewer-tier user doesn't get
  a misleading overlay.
2026-06-21 14:50:29 +02:00
maziggy c930c0e80d feat(printers): sort by ETA (#1609)
Adds an "ETA" option to the Printers page sort dropdown.
  Sorts the fleet by remaining print time so the printer that's
  finishing next sits at the top — useful for staging the next
  job's filament ahead of time.

  Tier ordering:
  - Tier 0: currently printing with remaining_time > 0
    (sorted ascending by remaining minutes)
  - Tier 1: currently printing without an ETA yet
    (post-start_print window before total time is known)
  - Tier 2: idle / finished
  - Tier 3: offline
  Name tiebreaker within every tier. The asc / desc arrow
  still applies after tiers resolve.

  Data source is the cached remaining_time (minutes) on the
  per-printer status query — the same field the per-card ETA
  label and the fleet "next finish" badge already read from.
  No new backend round-trip; the sort consumes data that's
  already in React Query cache and updated on every WebSocket
  push.

  groupedPrinters returns null for ETA too — every printer's
  ETA is unique so section headers would just produce a header
  per row. Flat list, like the existing name sort.
2026-06-21 14:22:20 +02:00
maziggy 4d16faed76 feat(file-manager): sort folder tree by recent activity (#1770)
Reporter has a lot of nested cad / slicer directories and wanted
  "folders that just got a new 3MF" surfaced without scrolling the
  alphabet. Tree was always alphabetical; LibraryFolder.updated_at
  only bumps on rename / move, not on file-add inside the folder.

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

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

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

  Same change also fixes File Manager list-view column alignment:
  header and body were sibling grids with min-content as the trailing
  column, computed independently. Header empty trailing div resolved
  to 0; body action strip to ~220px. Different trailing widths gave
  the 1fr Name column different remaining space, shifting every fixed
  column to its right. Replaced min-content with fixed 220px in both
  auth-on / auth-off grid templates.
2026-06-21 13:31:50 +02:00