Commit Graph
1022 Commits
Author SHA1 Message Date
maziggy 78cbd82259 Scale the printer card's body text and icons with its size (#1848)
Switching a card from M to XL made it wider, enlarged the printer name
and the thumbnail, and left everything else where it was. The AMS slot
labels, temperatures, filament names, status text and every small button
stayed pinned between 8 and 11 pixels -- under the smallest size used
anywhere else in the app -- so a full-width card carried the same tiny
text as the compact one. Browser zoom does not answer this: it enlarges
the whole page and so preserves the very disparity being reported.

The card root now carries ten custom properties derived from cardSize,
and the 200 fixed sizes in its subtree reference them: text-[10px]
becomes text-[length:var(--pc-t10,10px)], w-3 h-3 becomes
w-[var(--pc-i3,0.75rem)]. L draws the body 20% larger and XL 40%,
icons included, so the controls grow with the text instead of staying
fiddly to hit.

Custom properties rather than an em-based root font-size. Setting
font-size on the card would silently reshape any text that declares no
size of its own, and would break for portalled content. Each converted
class names its old fixed value as the fallback, so anything rendering
outside a card root is untouched -- which is what leaves the portalled
temperature popover exactly as it is. Its four sites stay fixed on
purpose, as does the page chrome; the conversion was scoped from the
function declarations rather than line numbers, and afterwards only
those four intended sites still hold a literal px value.

S and M stay at 1.0. S is the dense fleet view where density is the
point and M is the default, so an existing install looks identical until
the user reaches for a size that is already asking for more room -- the
same control the request asked this to follow.

The AMS-HT card needed separate work, because its temperature and
humidity readings sit beside the slot rather than under it. That single
slot was the only growable item on its row, so it took every spare pixel
and pushed the readings hard against the card's edge; it is now capped
at roughly two ordinary slots, which keeps them clear at any card width.
The card itself is capped at one full AMS card's width, so a unit that
wraps onto a line of its own no longer stretches that slot across the
whole card.

The AMS slot minimums are deliberately NOT scaled. Raising them was
tried and reverted: those cards already grow to fill their row, so
3.5rem is a floor they sit well above, and raising it only cost a unit
its place on the row -- which is what pushed the AMS-HT onto a line by
itself and exposed the stretching above. A test pins them at 3.5rem at
XL so this reads as a decision rather than a missed spot.
2026-08-04 14:09:10 +02:00
maziggy 45b678692c Scale the printer card's body text and icons with its size (#1848)
Switching a card from M to XL made it wider, enlarged the printer name
and the thumbnail, and left everything else where it was. The AMS slot
labels, temperatures, filament names, status text and every small button
stayed pinned between 8 and 11 pixels -- under the smallest size used
anywhere else in the app -- so a full-width card carried the same tiny
text as the compact one. Browser zoom does not answer this: it enlarges
the whole page and so preserves the very disparity being reported.

The card root now carries ten custom properties derived from cardSize,
and the 200 fixed sizes in its subtree reference them: text-[10px]
becomes text-[length:var(--pc-t10,10px)], w-3 h-3 becomes
w-[var(--pc-i3,0.75rem)]. L draws the body 20% larger and XL 40%,
icons included, so the controls grow with the text instead of staying
fiddly to hit.

Custom properties rather than an em-based root font-size. Setting
font-size on the card would silently reshape any text that declares no
size of its own, and would break for portalled content. Each converted
class names its old fixed value as the fallback, so anything rendering
outside a card root is untouched -- which is what leaves the portalled
temperature popover exactly as it is. Its four sites stay fixed on
purpose, as does the page chrome; the conversion was scoped from the
function declarations rather than line numbers, and afterwards only
those four intended sites still hold a literal px value.

S and M stay at 1.0. S is the dense fleet view where density is the
point and M is the default, so an existing install looks identical until
the user reaches for a size that is already asking for more room -- the
same control the request asked this to follow.

Wiki notes the scaling in the card-size table and why it differs from
browser zoom. Tests pin the variable values at every size, including
that S and M still emit the pre-change sizes.
2026-08-04 13:39:54 +02:00
maziggy 3db8ac9da7 Let the external spool be hidden from the printer card (#1782)
An external spool holder that never gets used still takes a full card's
width in the Filaments row, next to the AMS units that are actually in
use. An eye icon at the right-hand end of that row's header now hides
it, and clicking it again brings it back -- the affordance stays in
place rather than moving to a settings page, so the choice is
discoverable and reversible where it applies.

Per printer rather than global. A global flag would suit a toolbar
button, but an icon on the card that silently rearranged every other
card would surprise; it is keyed by printer id in one localStorage
entry, the same shape as printerCollapsedSections, and sits alongside
the other browser-local printer-page view preferences.

The toggle is offered only when the printer has at least one AMS. On a
machine with no AMS the external spool is the entire filament section,
so hiding it would leave an empty row with no control to undo it. The
icon and the hide condition read the same canHideExternalSpool, so a
preference stored before an AMS was unplugged cannot blank the row
either -- the spool reappears instead.

The store lives in a new utils/printerCardPrefs.ts rather than in the
9,157-line page. It re-reads before writing so two cards toggled in one
session cannot clobber each other's entry, deletes the key instead of
storing false, and treats a malformed or unavailable localStorage as
"nothing hidden" so a private-mode browser cannot throw out of a render.
2026-08-04 13:08:30 +02:00
maziggy 9bc96aeb83 Hold error and warning toasts for twice as long
Every pop-up notification auto-dismissed after three seconds regardless
of what it said. That suits "Settings saved" -- a confirmation of
something the user just did, skimmed rather than read -- but errors and
warnings are a different kind of message. They carry a reason, often one
relayed from the printer or the backend, and they run to a couple of
lines. Three seconds was not enough to finish reading one, and there is
no notification history to go back to once it slides away.

Errors and warnings now hold for six seconds; success and info keep the
three-second default. The duration was a bare literal in showToast and
is now derived from the toast type, with the long window expressed as
twice the base so the two cannot drift apart if the base is retuned.

showPersistentToast never had an auto-dismiss timer and is untouched, as
is the background dispatch toast -- its timer measures "the summary has
stopped changing" rather than reading time. Manual dismissal is
unchanged for every type.
2026-08-04 12:54:24 +02:00
maziggy 33ab5f1ead Add temperatures to the streaming overlay and a URL builder (#1422)
The overlay at /overlay/{printer} draws live print data over a
full-screen camera view for OBS, a wall display or any browser source.
It has been tunable since it shipped -- which fields, what size, what
frame rate -- but only through query parameters documented in the wiki,
and temperatures were not among the fields on offer. The request asked
for temperatures first and for the field set to be selectable in the web
UI; this addresses both.

Nozzle, bed and chamber readings join the list. The target is drawn only
while the heater is still climbing, so a settled hotend reads "220°C"
for the rest of the print instead of the noisier "220 / 220°C" -- 219.6
against a target of 220 rounds to the same number, and repeating it says
nothing. Both nozzles appear on a dual-nozzle machine. They are drawn
whether or not a print is running, because a preheating printer is
exactly when they are worth watching, and each reading appears only when
the printer genuinely reports one: chamber temperature stays absent on
P1 and A1 models, which publish a chamber_temper with no sensor behind
it, so the overlay never puts a measurement on screen that does not
exist. Labels reuse the heater chart's strings rather than inventing a
second vocabulary for the same three things.

The feed sends an allow-list rather than the temperatures dict. That
dict doubles as the MQTT client's working memory -- derived heater flags
and private target-set timestamps live alongside the readings -- and an
overlay token is a narrower grant than a login, so it gets exactly what
the overlay draws and does not pick up fields as the dict grows. The
same chamber-sensor gate the full status payload already applies is
applied here. The integration test that asserts the payload's exact key
set, which exists to catch that surface widening silently, is updated
deliberately.

Temperatures are not in the default field set, so an overlay URL already
pasted into a scene renders identically after upgrading.

Settings -> API Keys -> Streaming Overlay now builds the URL: printer,
field checkboxes, size, frame rate, camera toggle, an optional token,
and a copy button. It persists nothing and calls nothing new -- the URL
is the configuration, which keeps a scene reproducible by copy-paste and
lets two displays show different fields off one token. Fields are
emitted in the overlay's own top-to-bottom order rather than click
order, and parameters left at their default are omitted, so the same
selection always produces the same URL. The preview alongside it stays
off until asked for: an always-live iframe would hold a subscriber on
the printer's single camera connection for as long as the settings tab
stayed open.

The preview needed one narrow security-header change. Every SPA route
sent frame-ancestors 'none', which is stricter than the SAMEORIGIN in
X-Frame-Options beside it and refuses even a same-origin frame, so the
preview showed Firefox's "another site has embedded it" page instead of
the overlay. The overlay path now sends 'self', mirroring /gcode-viewer,
which admits a framer only on this origin -- Bambuddy's own UI. Every
other path keeps 'none', and embedding the overlay from another host
still requires TRUSTED_FRAME_ORIGINS.
2026-08-04 12:38:36 +02:00
maziggy 71a06f3638 Add batch orders with a quantity per plate (#342)
Printing a multi-plate file in different quantities per plate meant
queueing each plate separately and tracking the counts by hand: one
shared Quantity field cannot say "plate 1 once, plate 2 twice, plate 3
three times". Each selected plate now carries its own quantity, and the
submission becomes an order on a new Batches tab.

The point is the distinction the old flat batch could not express.
print_batch_plates stores how many runs of each plate were wanted,
separately from what was queued, so a run that fails, is cancelled or is
skipped does not satisfy a target -- the order goes on saying it owes a
print instead of quietly under-delivering. Queue remaining re-queues
exactly what is missing, for the whole order or one plate, by cloning
the most recent item for that plate: that inherits the printer or model
target, AMS mapping, filament overrides and print options along with the
validation they already passed, rather than re-serialising twenty fields
through a template that would drift from the model the first time
someone adds a column. Clones append to the end of the relevant
printer's queue and take the same advisory lock the add-to-queue route
does; positions are per-printer sequences, not global.

Cost is measured, not estimated. print_log_entries gains queue_item_id,
set where the queue item is already in scope, so each run's material and
energy are attributed through the item that produced them -- an
unrelated reprint of the same archive never lands in an order's total,
and a multi-plate order gets each plate's own cost rather than the whole
file's via the plate-scoped estimate from #2614. Before any run has
completed there is no honest figure, so cost reads as unknown instead of
a fabricated 0.00.

The Batches tab wires up GET /queue/batches, which has been unreferenced
since the batch MVP shipped, along with six locale keys that were
translated and never used. It is a separate tab because an order
outlives the queue that produced it: once its runs finish they leave the
active queue, so Queue and History each hold half the picture.

completed was not a reachable status before now, so every batch created
since April is still marked active however long ago its last print
finished -- 73 of them on the development install. A startup pass closes
out the finished ones: those whose runs all completed become completed,
and groupings whose items were all cancelled become cancelled, which is
what they are. Not applied to orders, which state their intent
independently of their runs and still owe the work. Only batches with
nothing queued or printing are considered, and repeating the pass also
catches an order whose last run landed while the process was down.
Batches with neither items nor targets are no longer listed at all --
empty shells left when a grouping's items went with their source
archive.

Dispatch applies the same source-file gates as POST /queue/. It creates
queue items, so without them it would be a weaker door to the same
outcome; the archive and library-file checks move into shared helpers
so a third route cannot drift from them.
2026-08-04 11:11:36 +02:00
maziggy 28a6ca6f4d Add CAP_NET_BIND_SERVICE everywhere the service is defined (#2549)
The Virtual Printer binds 990 and 322, below 1024, which a service running
as a normal user may not do without CAP_NET_BIND_SERVICE. Without it the
rest of Bambuddy works and only the VP is dead -- sockets never open, the
slicer never finds the printer, and the sole trace is one journal line.

332a7c6ac added the line to install/install.sh in March under the heading
"Fix install.sh missing AmbientCapabilities". Three other places define the
same unit and none of them got it: the manual template, the combined
Bambuddy + SpoolBuddy installer, and the unit the wiki tells you to paste.
The wiki additionally claimed the capability was always included.

Also diagnose it. The VP diagnostic reported only that nothing was listening
on 990, which reads identically to a port conflict. It now checks CapEff for
the capability and names it as the cause -- but stays quiet when the port is
answering (an iptables REDIRECT is the documented alternative and that host
works) and when the capability is held (the port is down for another reason
and blaming this would misdirect). Skips where there is no procfs rather
than putting a systemd instruction in front of a macOS user.
2026-08-04 08:33:30 +02:00
maziggy c28e053126 Keep live updates flowing while the tab is in the background (#2754)
Printer status, query invalidations and the message queue all ran their
work inside requestAnimationFrame. A hidden tab gets no rendering
opportunities, so the browser holds those callbacks instead of merely
throttling them: the socket stayed open, messages kept arriving, and
every cache write parked in a pending frame until the tab was shown
again — at which point they all ran at once. The tab-title progress
reads ['printerStatus', id] and nothing else, so it simply froze.

The frames came in with the print-completion freeze fix, where the
load-bearing part was the coalescing (100ms throttle, 3s debounce,
500ms stagger). That is untouched; the frames only deferred each write
by ~16ms and are gone. Not made visibility-aware on purpose — a frame
scheduled just before hiding would fire after the writes that took the
hidden path and clobber newer status with older.

The six rAF stubs in the tests ran frames synchronously, which is why
nothing caught this. Replaced with coverage that stubs rAF to never
fire, as a hidden tab does.
2026-08-04 07:56:27 +02:00
maziggy 8dde48587e Move the bug-report trigger out of the contended corner (#2750)
The floating disc is pinned bottom-right, which is where most controls
live — it covered ~83% of the Profiles scroll-to-top button at the same
z-index, and being viewport-fixed it also sits on card action buttons
that scroll under it. Below the sidebar-compact breakpoint the trigger
moves into the top bar; at 1144px and up nothing changes.

Not a hide switch: the bubble is the only entry to the report form, and
that form runs the printer diagnostic, the log scan and the debug
capture. Hiding it yields reports with nothing attached.

The panel stays at the Layout root — the header is a fixed z-40
stacking context and would bury a nested z-50 panel under every modal.
Also fixes the panel hanging 16px off-screen on phones: w-full resolves
against the viewport, so right-4 pushed its left edge negative.
2026-08-03 15:44:10 +02:00
maziggy dbf674561c Sort the inventory by colour, not colour name (#2729)
The Color column was missing from the page's sort-extractor map, so its
header ignored clicks. Sorts by family first — rainbow, then browns,
then neutrals light to dark — with the hue sort running inside each.

The issue asked for a straight hue/saturation/lightness sort. Measured
against a real 30-spool inventory that puts Titan Gray (hue 210, sat
0.04) among the blues and a warm grey next to the reds, and splits the
oranges around brown. Neutrals order by lightness because their hue is
noise.

Families come from the classifier that already names colours missing
from the catalog, so the Color and Color Name columns cannot disagree.
2026-08-03 15:24:37 +02:00
maziggy 689f5276e4 Show the compose directory in the Docker update command (#2664)
The printed command only works from the directory holding the compose
file, which is the thing the user came to the page not knowing. Adds a
copy button, a saved Compose directory setting, BAMBUDDY_COMPOSE_DIR,
and best-effort detection from a bind mount's host path.

Compose records the directory on every container it creates, but reading
that label needs the Docker socket mounted in — root-equivalent access
for a convenience string. The mountinfo guess is a prefill only: its root
field is relative to the mounted device, so a compose dir on its own
mount loses that prefix, and nothing in the container can detect it.

The field is restricted to path characters. It is the one setting whose
purpose is to be pasted into a root shell, so "/opt/bambuddy; rm -rf /"
would otherwise render as a plausible update command.
2026-08-03 15:12:02 +02:00
maziggy a08d3e62f3 Show the Print Log's per-run cost and energy, and let users pick columns (#2636)
The list and update endpoints serialised field by field and never named
cost / energy_kwh / energy_cost, so values Bambuddy had been recording
all along went out as nulls. Both now validate from the ORM row, which
removes the chance to omit a field rather than patching the three that
were missing.

Adds a Filament Used column plus a Columns picker for Cost, Energy,
Energy Cost and Finished, persisted per browser.

Also fixes the log view being unreachable with zero archives: the empty
state ran before the view check, hiding a log that outlives the archives
it refers to.

---

Sort the Print Log by any column (#2636)

Adds sort_by / sort_dir to the print-log endpoint, driven by clickable
column headers. Server-side because paging is: ordering the rows the
client holds would sort one page rather than the log.

Empty values are held last in both directions — Postgres sorts NULLs
high and SQLite low, so the same click would otherwise open on blanks
on one backend and values on the other. id DESC breaks ties so paging
through a low-cardinality sort can't repeat or skip a row.
2026-08-03 14:53:21 +02:00
maziggy e95c42c021 Add auto-orient and auto-arrange to server-side slicing (#2548)
Both are per-slice checkboxes, off by default, forwarded as the sidecar's
orient / arrange form fields. An unticked box is sent by omission: the
sidecar treats any present value as truthy, so a literal "false" would
have arranged every slice.

Arrange unions with the #1493 cross-class decision rather than replacing
it, and the per-plate slice-all loop is now keyed on the arrange flag
itself — the project-wide collapse belongs to --arrange, not to the
cross-class case. The loop also covers the embedded-settings path, whose
crash-retry is suppressed there since a single --slice 0 retry would
return one consolidated plate.
2026-08-03 14:10:02 +02:00
maziggy ea63355fde Fix cross-model queue items being misrepresented and editable into a broken state (#671)
The edit dialog offered a printer picker and target-model dropdown for
an item with alternatives. Saving left a row with variants AND a
printer_id, and the scheduler's fixed-printer branch wins that race, so
it dispatched a row whose library_file_id is still null and failed in
the upload. PATCH now refuses printer/model changes on such an item —
comparing against the current value, since the dialog re-sends
target_model unchanged — and the route eager-loads variants, without
which the guard could not see them and every PATCH response dropped the
alternatives from its payload.

Names come from a shared helper now. A cross-model item holds neither
archive_id nor library_file_id until dispatch, so five separate inlined
fallbacks all rendered "File #null"; they now read "x1c.gcode.3mf +1
more".

The queue also grouped these under "Any H2D" — the first candidate
mirrored onto the row — filing a job under a printer it might never run
on. It groups as "Any H2D / X1C", matching the row beneath it.
2026-08-03 12:33:19 +02:00
maziggy ef7c1b21f1 Add cross-model print alternatives to the File Manager and print modal (#671, #2570)
Selecting several sliced files and pressing Print now creates one queue
item carrying all of them, instead of hiding the Print button the moment
a second file is selected. The printer picker is replaced by the ordered
candidate list, since choosing these files is already the answer to
"which printer" and the only question left is which is preferred.

Per-candidate configuration is the plate only. Model-based assignment
sends no AMS mapping — the printer is unknown until dispatch, where the
scheduler derives it — so a per-candidate mapping editor would collect
choices it then discards. Filament overrides stay shared: "this job
needs PETG" holds for every slice of the same job.

Adds Group as versions for durable grouping, a versions badge counting
the whole group rather than the rows on screen, and a queue card label
naming every model a pending item is waiting on.
2026-08-03 11:33:39 +02:00
maziggy b04664c64a Raise the chamber-temperature ceiling from 60 to 65 C
Every field that takes a chamber target stopped at 60: the per-filament
chamber map and per-print override in Preheat & Heat Soak, the chamber
quick-select presets, and the printer-card chamber control. 60 is the
X1E's ceiling and the X1E was the only heated-chamber model when that
limit was written; the H2 series and X2D heat to 65, so the top of their
range was unreachable.

The ceiling now lives in one constant per side (MAX_CHAMBER_TEMP_C in
backend/app/utils/printer_models.py and frontend/src/utils/printer.ts)
rather than as a literal at each call site. X1E firmware clamps a higher
request to its own maximum, so a shared ceiling is safe.

Also fixes a live bug at PrintersPage.tsx:7985: parsePresetTriple was
bounded to 60 there, and it rejects the whole triple on any out-of-range
entry, so a saved 65 preset would have silently reverted the printer
card to the defaults while Settings still showed 65.
2026-08-03 08:52:23 +02:00
maziggy 30daed2756 Fix per-job queue ETA showing for jobs that cannot start now
The scheduler only writes waiting_reason on the model-based assignment
path, so a job pinned to a specific printer sits behind a running print
with no marker at all. Every such job rendered an identical "starts now"
ETA that was wrong by the length of everything ahead of it.

Decide eligibility on the page instead: an item gets an ETA only when its
printer is idle and it is the item the scheduler would dispatch next,
following the same ordering the scheduler uses. Staged and future-
scheduled items do not block the item behind them, matching the
scheduler, and items conditional on a previous print are excluded.

The value also froze at first render, since react-query's structural
sharing keeps the queue reference stable and nothing re-rendered the row.
formatETA now accepts a base instant and the page drives it from a 30s
clock shared by every visible row.

Retire the borrowed printers.estimatedCompletion tooltip for a queue key
that says what the number means, translated into all 13 locales.
2026-08-02 08:28:26 +02:00
maziggy aef4f3a3e9 fix(oidc): strip the required BAMBUDDY_OIDC_* values and register the local-login bypass
A Kubernetes Secret written as a block scalar carries a trailing newline, and
the schema bounds the four required variables by max_length only, so an
unstripped issuer_url was stored and enabled and then raised httpx.InvalidURL
on the first click of the SSO button -- the authorize-time failure the
all-or-nothing rule exists to prevent. Whitespace-only values got through the
same way, contradicting the reader's own "an empty required var counts as
unset". The optional variables have always treated blank as unset; the
required ones now do too.

Also registers BAMBUDDY_LOCAL_LOGIN (#1589) in the typo guard, which logged
"possible typo" for it on every boot while listing every BAMBUDDY_OIDC_*
variable as legitimate.
2026-08-01 11:37:01 +02:00
maziggy 43cb216ae9 fix(settings): stop the Settings page reverting changes made elsewhere (issue #2716)
While the Settings page was mounted it held its own copy of every
setting and synced it from the server exactly once, on first load
(:887-900). A debounced effect then diffed the live ['settings']
cache against that copy and PUT all 77 keys it manages on any
difference, with no way to tell a user edit from a value that had
changed on the server. Anything written server-side while the page
sat open was silently reverted ~500ms later (#2716, reporter
@jmoore-skild).

No interaction was needed to trigger it. The query inherits a 60s
staleTime and react-query's default refetchOnWindowFocus, and ~30
other observers share the key, so a window refocus or a refetch from
any of them moved the cache and the page wrote its page-load snapshot
back over all 77 keys -- showing "Settings saved" while doing it.

The page now tracks the last server snapshot it reconciled with. A
field still equal to that baseline has not been touched since, so a
newer server value is adopted; a field the user has edited keeps
their value and is saved over the top, so the newer of the two writes
wins either way. Typing into a text field while a refetch lands stays
safe, which is what the previous behaviour was protecting -- an
in-progress edit is by definition different from the baseline.

The baseline is seeded from the raw server row rather than from the
copy the page patches a browser-detected external_url into, so that
detection still reads as a local change and is still persisted.

The payload builder and the comparison key lists are unchanged. The
diff simply measures against the baseline instead of the live cache,
so no field can silently stop saving.

Removing the adoption step was verified to reintroduce the revert,
and removing the post-save baseline advance to reintroduce a resend
loop; both are covered by frontend tests asserting on the request
bodies rather than on rendered values.
2026-08-01 10:55:08 +02:00
maziggy 18938a10ee fix(kprofiles): stop reporting rejected K-profile writes as saved
Saving a K-profile was fire-and-forget. set_kprofiles_batch published
and returned True, and the printer's extrusion_cali_set answer was
logged at DEBUG and dropped, so a write the printer refused was
reported to the user as saved (#2718, reporter @jmoore-skild).

The reason it could not simply be gated on: the answer itself was
wrong. Single-nozzle firmware returned result:"fail" with
reason:"invalid tray_id" on writes that demonstrably applied.
Measured against an X1C and an H2D over MQTT, the cause is the
tray_id:-1 Bambuddy itself put in the payload. Sending three
otherwise identical writes isolated it: tray_id:-1 fails, tray_id:0
succeeds, and cali_idx:-1 is accepted either way, so only that one
field is at fault. The H2D ignores the value entirely; the X1C
validates it, complains, and applies the write anyway. BambuStudio
always sends a real tray_id and defaults it to 0 for a manually
entered profile.

With tray_id:0 the acknowledgement is honest, and the printer echoes
back the sequence_id we sent -- confirmed for extrusion_cali_get,
_set and _del on both printer classes -- so it can be matched to the
write that caused it. Writes now return their sequence_id and the
routes await the verdict, turning a real failure into an error that
carries the printer's own reason. A printer that stays silent is
still treated as success: no answer is not evidence of refusal, and
firmware that never answers must not turn every save into an error.

Raises the ack to INFO. It sat at DEBUG, so the one line that
explains a failed save was absent from every support bundle -- the
same reasoning that put ams_filament_drying at INFO for #1447.

Also fixes extrusion_cali_set building its payload from
str(self._sequence_id) without incrementing first, reusing the
previous command's id. Harmless while nothing correlated on it,
fatal now that the write path does.

Adds supports_nozzle_flow_type() for the Standard / High Flow choice,
which the K-Profiles UI previously showed as "Not reported by
printer" -- not a value anyone can save. Most printers omit the
nozzle identity from their calibration table entirely, and the slicer
treats that as Standard rather than unknown; Bambuddy now does the
same and keeps the choice editable. The field is hidden only where
the model ships a single nozzle variant, using the slicer's own rule
(len(nozzle_volume) // len(nozzle_diameter) > 1 over the machine
preset) evaluated across every bundled Bambu profile. That puts only
A1, A1 Mini and A2L on the hidden side -- it is not the single-
versus-dual-nozzle split, since P1P, P1S, P2S, X1, X1C, X1E and H2S
are all single-nozzle and all carry two variants. Editing a profile
also no longer writes back an empty nozzle_id.

Wiki records that on printers which omit the field the chosen flow
type is discarded by the firmware and reads back as Standard, in
Bambu Studio as well, so it does not get filed as a bug again.
2026-08-01 10:35:22 +02:00
maziggy af282b3527 fix(kprofiles): populate the filament picker from all preset tiers (issue #2719)
Add K-Profile built its Filament dropdown from the profiles already on
the printer, so on a printer with none the field was empty, required
and unsatisfiable (#2719, reporter @jmoore-skild). The modal's own
hint described the dead end: create the profile in Bambu Studio first.

The dropdown now uses the app-wide lookup order -- local imported,
Orca Cloud, Bambu Cloud, hardcoded built-in table -- same as the AMS
slot picker and the SliceModal tier groups. The built-in table is
compiled into the backend, so the list can never be empty: a new
printer with no cloud account and nothing imported still gets a first
profile.

Not fixed the way the report suggested. Seeding from
/printers/available-filaments would have offered only what happens to
be in an AMS right now, which on the reported printer is nothing; its
tray_info_idx is empty or a cloud user preset rather than a filament
id; it aggregates across every printer of the same model; and it is
gated on QUEUE_CREATE, which the K-Profiles page does not hold.

The printer indexes its calibration table by filament_id, so the
picked preset is reduced to one before anything is sent. Built-in
entries and Bambu official cloud presets carry one; a cloud user
preset needs its detail fetched (never base_id -- that collapses a
custom preset onto its inherited generic, #1053); imported and Orca
presets have no Bambu id at all and take the closest generic for
their material, via the same table the AMS slot configure flow uses
so the two agree. A filament that resolves to nothing is refused with
a named error rather than written under a wrong id.

Collapses duplicates from two separate causes. A cloud account
carries one copy of each filament per printer model, and with the
"@BBL <model>" suffix stripped for display those rows are
indistinguishable -- deduped within each tier by resolved filament id,
by display name for user presets that have none. Cloud setting_ids
also carry a "_NN" variant suffix, so the built-in tier's
already-covered check never matched and listed the same filament
again; the bare id is now recorded alongside.

Groups the options by source with an optgroup per tier, styled in
index.css: browsers render optgroup labels small, grey and italic,
which buries the one thing distinguishing a "Bambu PLA Basic" you
imported from the one the built-in table ships.

Drops the second getKProfiles(printer, "0.4") query that existed only
to seed the old dropdown. It ran concurrently with the main fetch
whenever a non-0.4mm nozzle was selected -- the two-requests-in-flight
case that made K-profile fetches time out.

---

fix(ui): cancel a dialog's deferred close when it unmounts

The AMS slot configure and K-Profile dialogs hold a success state
briefly and then close themselves -- 1.5s to 4s after the command
goes out, so the printer has time to process it before the list
refetches. Each did that with a bare setTimeout closing over setState
and the parent's onClose, and nothing cancelled it.

The timer therefore ran whether or not the dialog was still there.
Dismissing it inside that window, or the printer card re-rendering
underneath it, left a pending close that fired later and dismissed
whatever dialog was open by then. It also threw outright when the
surrounding environment was gone first: a test tearing down its DOM
before the 1.5s elapsed produced "ReferenceError: window is not
defined" out of react-dom's resolveUpdatePriority, reported as an
unhandled error against a suite that otherwise passed.

Routes all five through a useCancellableTimeout hook -- two in
ConfigureAmsSlotModal, three in KProfileModal, the latter with the
longest windows and so the widest exposure. Scheduling replaces any
pending timer and unmounting clears it.
2026-08-01 09:36:42 +02:00
maziggy a35ba8fa5f fix(kprofiles): read the nozzle diameter the printer actually sent (issue #1748)
Every K-profile came back as 0.4mm on printers running any other
nozzle (#1748, reporters @Liquidmasl and @jmoore-skild). The printer
puts nozzle_diameter on the extrusion_cali_get envelope only; the
per-filament entries carry setting_id, filament_id, name, k_value,
n_coef and cali_idx, and nothing else. The parser read the field per
entry with a hardcoded "0.4" fallback, so the fallback fired on every
profile of every response. The envelope value was already in scope,
read into response_nozzle and used only to match the request.

This never reproduced on H2D because that firmware does include the
field per entry. Both construction sites are in the same handler, so
the code path is shared; what differs is the payload, and every
single-nozzle model omits it.

The display was the least of it. Editing is delete-and-re-add on
single-nozzle printers, and the dialog rebuilt nozzle_id and
nozzle_diameter from its own greyed-out selects, so saving an
untouched 0.6mm profile rewrote it on the printer as HH00-0.4.
Deleting aimed extrusion_cali_del at the wrong nozzle the same way.
Both now pass through what the printer reported. The cali_idx cascade
in inventory.py, spoolman_inventory.py and spoolman.py matches on
nozzle_diameter, so on a 0.6 or 0.8 nozzle it never found the
printer-side entry and the assignment silently failed to stick --
that is the "cannot auto-map a K-profile" half of the report, fixed
at the source without touching those three call sites.

nozzle_id has no source in the payload at all, and state.nozzles
carries material (hardened_steel), not flow, so it cannot honestly
produce HH/HS. Rather than keep inventing one, the UI now says the
printer did not report it: the card shows the diameter alone, the
dialog shows "Not reported by printer", and the High Flow / Standard
filter is hidden instead of being offered as a control that can only
ever empty the list. Import stops stamping HH00 on profiles whose
source reported none.

Also correlates K-profile requests by sequence_id. Responses were
matched by nozzle diameter through a single shared expectation slot,
so a second request overwrote the first's and the first's valid
answer was discarded as a mismatch -- the "Failed to get K-profiles
after 3 attempts" in the same logs, with the printer having answered
correctly both times. Pending state is now one entry per request,
keyed by the id we already send, with the nozzle match kept as a
fallback for firmware that does not echo it back.

Fixes the flow-type select naming a new profile with the opposite
label, which contradicted the identical expression 44 lines above it.
2026-08-01 08:49:44 +02:00
maziggy 455a9e4ba7 fix(backup): collect cloud profiles from every connected account (#2717)
Enabling Cloud Profiles for a Git backup produced nothing, and said it had
worked. Two independent faults, either one sufficient.

The collector looked for a "setting" list. The Bambu Cloud listing endpoint
is keyed by preset type instead, each key holding private and public arrays,
so the loop body never executed once — and the entries carry no type of
their own either, which routes/cloud.py already knew: it takes the type from
the outer key and maps Bambu's "print" to process. Two bugs on one line.

It also asked build_authenticated_cloud for the credential store used when
authentication is disabled. With auth on, tokens live on User rows, so the
collector returned at "Cloud not authenticated" before ever reaching the bad
key. Every multi-user install was collecting from zero accounts.

Neither failure surfaced. backup_metadata.json recorded the configured flag
rather than the outcome, so it claimed cloud_profiles: true on runs that
wrote nothing, and the log read "Collected cloud profiles: 0 filament, 0
printer, 0 process" at INFO — which is exactly what a successful backup of
an empty account looks like.

Cloud profiles now come from every connected account across both clouds. The
toggle predates Orca Cloud entirely, and Orca has the same three preset
types, so both are collected and grouped the same way:

    cloud_profiles/bambu/user-3/{filament,printer,process}.json
    cloud_profiles/orca/user-3/{filament,printer,process}.json

Accounts are keyed by Bambuddy user id, "global" when auth is off. Never by
email: a backup repository can be public, and the Bambu listing's user_id is
dropped for the same reason. Both credential stores are read on every run,
because a Settings row survives someone enabling auth later and dropping it
would silently stop backing that account up.

Bambu costs one get_setting_detail per private preset. The listing is
metadata only, and without base_id and setting the backup is a list of names
that create_setting cannot rebuild from. Public presets are skipped — Bambu's
bundled catalogue is the same hundreds of entries for everyone, always
re-downloadable, not recreatable under your account, and would rewrite the
repository on every run. Orca needs no second call; its sync-pull carries
each profile's content inline. Where the Orca route drops a profile whose
content.type it cannot map, the backup writes it to other.json instead:
silently omitting a profile because Orca added a type is the same class of
bug as this one.

Failures are contained per account and per preset, and counted rather than
swallowed. A partial backup that looks complete is how this stayed invisible.

The metadata now reports what was collected, per cloud and per account, and a
run that collects nothing while the category is enabled warns with the reason
instead of an INFO line that reads like success.

The checkbox gated on the viewer's own Bambu sign-in, which is not the same
question as whether there is anything to back up — with auth enabled the
accounts belong to individual users, and an administrator who never signed
in personally saw the category disabled with plenty in scope. It now gates
on the total across both clouds and shows the counts. That comes from its
own endpoint rather than a field on /config, since /config answers null
until the first save and would disable the toggle during the very setup it
belongs to. Counts only, never identities.

One deliberate restraint. _build_authenticated_service clears stored
credentials when a refresh is rejected, which is right for a route — the
user is on the page and can pair again — and wrong for a scheduled job.
Orca reports every rejection with one composite reason ("unknown, expired,
revoked, or already used"), so a genuine revocation cannot be told apart
from a lost token-rotation race, and acting destructively on a signal that
cannot be disambiguated is the #2562 mistake in a different cloud. It also
gains nothing: the Profiles route hits the same failure and clears it then,
with the user present. Background callers now pass clear_on_auth_failure=
False and skip the account. A successful refresh is still persisted either
way — by that point the old token is consumed, so dropping the new pair
would break a working pairing for real.

Restore is not part of this. Nothing reads cloud_profiles/* yet; the format
carries base_id/setting for Bambu and content for Orca so that it can.
2026-07-31 16:59:33 +02:00
maziggy 4f2c073a34 fix(vp): gate the slicer's AMS pick behind the toggle and scope its badges (#2700)
Round-3 review of the "Save AMS mapping" PR.

The queue item's ams_mapping was set unconditionally, on the reasoning that
honouring the slicer's own pick is a correctness fix rather than a feature.
It is both. Storing a resolved mapping makes _ensure_ams_mapping return
early, so _compute_ams_mapping_for_printer never runs — and that function is
where prefer_lowest_filament lives, along with the AMS-filament-backup gate
that qualifies it (#1766), the inventory-remain overrides, and the per-slot
force-colour overrides. Every existing queue-mode VP pointed at a printer
would have quietly lost all of it on upgrade, without a setting to turn it
back on.

So save_ams_mapping now gates the queue item too, not just the archive
persistence. Off is exactly the old behaviour. The correctness case the PR
was written for — two spools of the same red PLA, and the slot the user
picked in the slicer thrown away — is still fixed, for anyone who asks for
it.

Force color match wins over it when both are on. Its only effect on a
fixed-printer item is the filament_overrides written onto the queue item,
and those are read inside the function a stored mapping skips, so the two
toggles sitting next to each other on the same card silently cancelled. The
dispatch now matches strictly, as asked, while the slicer's pick is still
saved onto the archive — that is what the toggle's name promises, and a
later reprint is a separate decision from this print. The queue-add fallback
applies the same rule to a request that carries force-colour overrides.

A mapping shorter than a plate's highest slot id cannot address that plate's
own slots, and _ensure_ams_mapping would have kept it anyway, since it only
rejects an all-unresolved one. Each plate now checks the length it needs and
falls back to a computed mapping if the array does not reach. Bambu Studio
sends a file-global array, so this normally never fires; it also means a
multi-plate Send All degrades safely if that ever stops being true.

The badges claimed more than they delivered. Both rendered whenever a saved
mapping existed, ignoring which printer it belonged to, while the tooltips
promised the reprint would reuse those exact spools — true only on the
printer the trays were resolved against. The queue row's flag is now
computed against that row's own printer, which is precisely when dispatch
reuses the mapping, and the archive card names the printer instead of
implying any of them will do. It hides itself when that printer no longer
exists. Retranslated in all 13 locales.

Frontend tests, which the PR had none of. The printer-scoping rule is now a
pure function rather than an inline expression, covered for the mismatched
printer, the no-printer-selected case that would otherwise compare undefined
against undefined, and malformed extra_data. The toggle's undo bookkeeping
is covered for unresolved slots, short mappings, and hand-made picks —
preserved when the toggle never wrote that slot, replaced when it did, which
is behaviour worth pinning either way.

Also reverts all three queue-mode switches when a save fails, not just the
new one; without it the card shows a setting the server rejected.
2026-07-31 16:16:39 +02:00
maziggy ce3e59884a fix(slicer): bound slices by silence, not by total slicing time (#2730)
A heavy MakerWorld model — one Bambu Studio also takes a long time over —
failed after five minutes with "Slicer sidecar unreachable". The sidecar
was reachable the whole time and still slicing when we hung up on it.

SlicerApiService carried a hardcoded 300s timeout, passed to httpx as a
bare float so it covered connect, read, write and pool alike. On a single
long request that is not a health check, it is a cap on how long a model is
allowed to take. And because httpx.ReadTimeout subclasses RequestError,
expiry landed in the same handler as a refused connection and was reported
as an unreachable sidecar — so the reporter went and updated their sidecar
container, which was never the problem.

The information to do better was already being collected. _poll_progress
polls /slice/progress/{id} once a second alongside the blocking POST to
drive the live progress toast, so at minute five Bambuddy had fresh
evidence the slicer was working. It killed the request anyway.

So the read timeout comes off the HTTP call and the poller supervises
instead: the deadline moves forward on every progress update, and only
genuine silence ends the wait. A model that keeps reporting runs to
completion however long it takes. Connect and pool keep short timeouts —
a sidecar that will not accept a connection is unreachable and should
still say so quickly.

Only a *changed* progress payload counts as alive. The sidecar re-serves
its last snapshot on every poll, so counting repeats would leave the
watchdog unable to detect a stall at all.

The window is floored at three poll intervals: liveness can only be
observed as fast as the poller ticks, so anything shorter would expire in
the gap between two polls and fail every slice instantly.

New setting slicer_stall_timeout_minutes (Settings > Workflow > Slicer),
default 15, range 1-240, alongside the sidecar URL and gated on
use_slicer_api like its neighbours. Sidecars too old to report progress
have no liveness signal, so for those the same number bounds total elapsed
time — the old behaviour, configurable and no longer 300s flat. The
message says which case applies and where to change it.

SlicerTimeoutError is its own type and maps to 504, not 502: the sidecar
answered throughout, we stopped waiting. Connection failures keep
SlicerApiUnavailableError. The preview slice path gets the same treatment.
2026-07-31 15:14:01 +02:00
maziggy 284709f850 fix(projects): drop deleted prints from their project, and refresh the view (#2731)
Deleting a print that belonged to a project left it on the project page as
a card with a missing thumbnail, and there was no way to remove it.

Deleting a print is a soft delete by default (#1343): the files go from
disk, the row stays so global Quick Stats keeps counting its filament,
time and cost. Every other consumer filters those rows out. The projects
module filtered none of them — the only deleted_at check in the whole file
was for LibraryFile — so a deleted print kept its project_id and kept
being listed, pointing at a thumbnail that no longer existed. The same
broken previews appeared on the overview cards, and in the timeline, where
the entry links to an archive that no longer opens. Unassigning was
impossible because the only UI that can change a print's project lives on
the Archives page, which correctly hides deleted prints: visible on the
project, unreachable from anywhere.

All eight project-scoped archive queries now filter, counts included. That
last part is a deliberate divergence from #1343, where the whole point of
the soft delete is that the contribution survives: a project is a piece of
work with a definite membership, not a lifetime total, so a project that
lists eleven prints must not claim twelve. The reasoning is recorded at
the constant so nobody later "fixes" it back.

remove_archives_from_project keeps working on hidden rows on purpose — it
is the repair path for links written before this. The BOM print_name
lookups are left alone; naming a since-deleted print is still correct.

Two more consumers had the same gap. The CSV/Excel export handed back rows
the interface says are gone — filtered at the base query, since the export
is the list you are looking at saved to a file. Per-project failure
analysis measured a failure rate against prints deleted from the project,
and disagreed with the project's own numbers; only the project-scoped
branch filters, global analysis still counts every run including orphans
as #1390 established.

Finally, the project page needed a manual reload to catch up. staleTime is
60s and the delete mutations invalidated only ['archives'], so a project
visited within the minute served its cached copy, print still there. The
project-assign mutations had the mirror-image bug: ['projects'] refreshed
the overview cards but never ['project', id]. Both now go through one
shared helper covering every project-derived key, as bare prefixes so all
cached project ids are matched.
2026-07-31 14:50:25 +02:00
maziggy 3abab1fd45 fix(printers): recover MQTT sessions that stopped reconnecting (#2732)
The reporter's printer lost its session to a keep-alive timeout at 02:19
and did not come back until 11:24 — nine hours offline, with the web UI
open throughout.

check_staleness() was never going to catch it. Its first line is
`if self.state.connected and self.is_stale()`, so it only ever handles the
half-broken session that is still connected but has gone quiet. This
client had connected=False from 02:19:42 (the offline notification fired a
minute later), so every call returned immediately, and paho's own retry was
the only thing left watching. When that stopped making progress nothing
noticed.

Adds a sweep every 60s that rebuilds a client when all four hold: it is
disconnected, it had a working session before, it has been silent for five
minutes, and its MQTT port still answers. The port check is what keeps this
from becoming a nuisance — a switched-off printer is left to paho, so a
farm powering down overnight causes no client churn and no log spam. The
five-minute grace sits well past the 60s stale timeout and the 30s max
reconnect backoff, so a session recovering on its own is never interrupted.

The rebuild goes through force_reconnect_stale_session from async context,
which takes the hard-reset path: fresh client_id and paho's QoS 1 queue
dropped, so a project_file left unacked on the dead session cannot replay
into the new one and trip 0500_4003 (#1136). Rate-limited per printer,
cooldown cleared when the printer returns, and the sweep continues past a
client that throws rather than abandoning the rest of the farm. The log
line names how long the printer was gone and the last connect error, so a
session that dies repeatedly leaves a trail.

check_port gains a public alias in printer_diagnostic rather than having
the watchdog reach for the private name.

Also corrects the Developer Mode path added in the previous commit: the
wiki documents it under Settings > Network, not Settings > General. The
menu path is dropped from the translated string entirely, since it varies
by model and firmware and the wiki carries the detail.
2026-07-31 14:28:17 +02:00
maziggy 5e2b7b53e6 fix(printers): surface the printer's own "command verification failed"
A P1S on firmware 01.10.00.00 rejected every control command and said so:
HMS 0500-0500-0001-0007, "MQTT command verification failed". Bambuddy
received that, dropped it, and reported a healthy printer instead.

The frontend filtered it out. This code's meaning lives in attr's low half
(0500) and code's high half (0001), both of which the MMMM_EEEE short form
discards, so it collapsed to "0500_0007" — no catalog entry, no firmware
actions, and filterKnownHMSErrors drops uncatalogued action-less errors.
Catalog lookups now try full_code first, in both the description and the
filter, and errors matched that way display the four-group code the
printer's own screen shows. The remedy line is ours, not Bambu's: their
wiki says to update Studio or Handy, which does not apply to a print sent
from Bambuddy.

The developer-mode probe made it worse. It read anything that was not an
explicit refusal as confirmation, and this firmware answers the probe with
an empty result while refusing everything else — so an inference drawn
from a non-answer became "developer_mode: pass" in the support bundle of a
printer that had not accepted a command all day. The probe now has three
outcomes: explicit success enables, explicit verify-failure disables,
anything else stays unknown and the diagnostic reports skip.

The HMS is authoritative over that inference in both directions. It forces
developer_mode False when present, and clears back to unknown when the
printer stops reporting it, so enabling Developer Mode and restarting the
printer is picked up without restarting Bambuddy.

Dispatch no longer treats a refusal as a wedge. The watchdog latches the
HMS across both phases and fails the item on the first attempt naming the
code and the fix, rather than spending three uploads and 270s a lap to
arrive at a message about SD cards. The check runs after the active-state
exit in both phases, so a lingering HMS can never abort a print that is
visibly running.

Also: the "wrong or mis-cased serial number" hint no longer fires in the
moment after a reconnect. _report_messages_since_connect is reset by
_on_connect, so a reconnect landing microseconds before the staleness
check leaves it at 0 for reasons that have nothing to do with the serial —
this reporter's healthy printer was told to go check its serial 1 ms after
reconnecting.
2026-07-31 14:14:58 +02:00
maziggy 11dc612bc4 feat(obico): authenticate to a token-protected ML API (#2733)
Obico's ml_api container takes an optional ML_API_TOKEN environment variable.
With it set, ml_api/auth.py answers a bare 401 to any request whose
Authorization header isn't "Bearer <token>"; with it unset it ignores the
header entirely. Bambuddy never sent one, so pointing it at a protected server
meant deleting the token there — which the reporter had set for their Home
Assistant integration and did not want to undo.

Settings -> Failure Detection gains an ML API Token field. When it is empty no
header is sent, so an unconfigured install's request stays byte-identical to
what shipped before the setting existed.

This failed in the worst possible way, and that is the more important half of
the change. Obico decorates /p/ with token_required but leaves /hc/ open. Test
Connection pinged /hc/, so it reported success against a server that was
rejecting every real detection call, the settings looked right, and detection
silently never ran. The only symptom was a generic "ML API call failed" buried
in the status card.

So the test now proves what it claims. After health passes it probes GET /p/
with no img parameter: the auth decorator runs before the handler, so 401 means
the token was rejected and 422 ("Invalid request params") means it was
accepted. No inference work is done either way. A probe that itself errors
reports the token as unknown rather than as working — the UI says it could not
be checked instead of claiming success.

The detection loop checks for 401 before raise_for_status, so a rejected token
is reported as a rejected token, naming the setting and the environment
variable, instead of surfacing "401 Unauthorized" with no hint of what to do.
The message never contains the token; a test pins that.

The setting name carries "token", so the support bundle's keyword redactor
masks it with no new rule. Resolving "field omitted" to the saved token is the
route's job, keeping test_connection a pure outbound call with no database
access.

Second fix, same issue: support bundles misreported which printers Obico
watches. The bundle split obico_enabled_printers on commas and read an empty
value as "no printers". The settings UI writes a JSON array, and empty means
*all* printers — the default — so a working Obico setup showed obico_enabled
false against every printer in its own bundle. That is the reporter's bundle
exactly, and it points anyone reading it at the wrong subsystem. The bundle now
parses the setting the way ObicoDetectionService does, keeps a comma fallback
for any install that stored the legacy shape, and factors in the global switch.
2026-07-31 13:39:17 +02:00
maziggy 6844aa292f fix(ams): offer every K profile the printer holds for a generic filament preset (#2710)
The reporter's A1 mini has nine Flow Dynamics calibrations, all of them saved
under Generic PLA and named after the spool's colour — "Dark Brown", "Glow",
"Marble". Bambu Studio lists all nine for that slot. Configure AMS Slot offered
one: the profile already bound to the slot. After a slot reset it offered none,
leaving the slicer as the only way to assign a K value.

Two independent faults, both tripped by picking a built-in generic preset.

The filament-id match discarded Bambu's generic GFx99 ids as too broad. But the
comparison already requires both sides to carry the same id, so that exclusion
could only ever fire when the selected preset was itself the generic one —
precisely the case where the match is right. The printer keeps one calibration
table per filament id, so a slot on Generic PLA should offer everything
calibrated under Generic PLA. Equal ids now match, generic or not.

The name fallback was dead for the same presets: parsePresetName reads the
leading "Generic" in "Generic PLA" as a manufacturer, which put the matcher into
brand-gated mode and demanded the word GENERIC appear in the profile name. No
real profile has it. "Generic" is no longer treated as a brand, so profiles still
match on material when a printer reports no filament_id with its calibrations.

The one profile that did appear came from the #1689 safety net that always
surfaces the slot's active cali_idx — which is also why a reset slot, having no
active profile, showed an empty list.

Neither fix can be complete on its own, because profile names are free text and
nothing ties "Marble" to a material. The picker now also lists every remaining
profile on the printer under "Other K profiles on this printer", so a profile
that exists can always be selected. Applying one from that group needs no new
backend work: configure_ams_slot already realigns the slot's filament context to
the chosen profile's, which is what makes the cali_idx stick.

Options are keyed by name+k_value rather than the bare name, so two profiles
sharing a name are no longer indistinguishable in the select. Both render blocks
carry the change — the modal duplicates the picker for its full-screen variant.

isMatchingCalibration gets the same generic-id rule for the spool form's PA
suggester, with two guards. A new generic-id-to-material table means a PETG spool
can never claim GFL99 profiles just because both sides stored a generic id
(Nylon and PA compare as one material). And a spool that names its own brand
keeps the stricter name path, so its suggestions stay brand-specific rather than
becoming the printer's whole generic table.
2026-07-31 13:15:35 +02:00
maziggy db6cdb0745 fix(camera): take the finish photo when the print ends, not when its last layer starts (#2547)
The photo fired the moment layer_num reached total_layer_num. That edge is
where the printer *starts* its final layer, not where it finishes it: the
reporter's H2C capture shows it arriving at 92% with mc_remaining_time=2,
three minutes and seventeen seconds and one filament change before the print
actually ended, so the frame caught the toolhead mid-print over the model.

The trigger also latched _finish_photo_captured, which locked out both the
stage-22 and FINISH triggers for the rest of the print — so on firmware that
never reports an end-of-print filament unload (H2C and A1 Mini confirmed)
nothing could replace the bad frame.

Remove the last-layer trigger. The photo is now taken at the FINISH-state
trigger, which every model sends and which lands after the toolhead parks.

Since Bambu's end G-code drops the plate ~100mm just before that, restore the
framing before capturing: absolute G90/G1 Z to max_z_height + 10mm clearance,
settle, capture, then drop it back so the print is as reachable as the printer
left it. Absolute is the safety argument — that Z is a height the toolhead
occupied seconds earlier, so it is inside the travel limits by construction and
leaves the nozzle above the part, and it is unambiguous across model families
because Z is the nozzle-to-bed gap whether the bed moves or the toolhead does.
M211 is never touched (#2579). This is what #1145, #1397 and #1565 asked for.

The height is only trusted when two independent sources agree: the archive is
matched by the finished print's subtask_name by equality (not LIKE, so "Cube"
cannot resolve to "Cube v2"), and its layer count from the 3MF must match the
layer count the printer reported over MQTT. Matching on "most recent archive
for this printer" was not safe — on_print_complete pops the _active_prints
binding concurrently, and a print Bambuddy failed to archive would have
resolved to its predecessor. A wrong height is the one failure that could drive
the nozzle into the model.

The move is additionally skipped when the print height is unknown, when a queue
item is pending for the printer, when the printer has left FINISH, and when the
new finish_photo_restore_plate setting is off.

for every FINISH-state capture — which is what shipped the mid-print photo —
the bank is used only when the dispatcher recorded that it injected End G-code
into this print, since a SwapMod snippet may have ejected the plate. The flag is
handed over in two steps (mark_pending at dispatch, adopt at print start) so it
can never outlive its print: a job started from the slicer or SD card adopts
False rather than inheriting its predecessor's answer. Those prints also skip
the plate move outright, bank or no bank.

The bank now refreshes on mc_percent advances as well as layer changes, via a
new on_print_progress callback. Layer changes stop the instant the final layer
begins, which left the #1867 fallback frame stale by the whole length of that
layer; progress keeps ticking there and freezes before the End G-code runs, so
a swapped plate still cannot reach the bank. The last-layer throttle exemption
is dropped, since it would now fire a grab on every percent tick.

On the timelapse path the moment producer returns early, so the consumer does
the restore itself before its live-grab fallback — the documented usual outcome
on P1-series, where the video has not transferred by the time the notification
goes out and the shipped photo was of an already-dropped plate. The two waits
are now derived from the settle window and the video poll timeout rather than
hardcoded; at the old flat 75s that fallback was guaranteed to be cut off
mid-settle.

extract_max_z_height_from_3mf reads only a bounded prefix of the plate G-code,
since a sliced plate is routinely tens of megabytes and the header is ~40 lines.
It returns None for missing, unparseable, zero and negative values so callers
must treat "don't know" as such rather than defaulting.
2026-07-31 12:55:05 +02:00
maziggy 4888485a54 fix(camera): redact credentials, contain failures, and stop the external-camera test claiming a connection it never opened
Review follow-ups on the external-camera capture coalescing.

The coalescing was transplanted from camera.py, which is keyed by printer IP
and so has nothing to hide in a log line. These keys carry the camera URL, and
an RTSP camera URL routinely embeds user:pass@ - so the five new log lines
printed the password, one of them at warning level, where it reaches support
bundles. All five now go through _log_key(), which redacts before truncating:
slicing first can cut the URL short of the @ the pattern anchors on and leave
the password intact, which is why every other URL log in the module already
does it in that order.

_capture_frame_uncoalesced gained the blanket catch its camera.py counterpart
has. That is load-bearing once captures are shared: the wrapper hands one
task's outcome to every caller waiting on it and can only give a follower its
own turn for an outcome it recognises, so an escaping exception reached all of
them at once and none retried - one caller's failure becoming N. The per-type
helpers catch narrowly (aiohttp.ClientError / OSError / timeouts), so the
guarantee belongs here rather than resting on their coverage. CancelledError
is re-raised ahead of it, since the wrapper distinguishes a cancelled leader
from a failed one.

test_connection reports whether it shared a capture. It reaches capture_frame
like any other consumer, so a test landing while Obico is polling got that
frame back and answered "connected" for a connection it never made - the one
answer a connection test must not give silently. It still shares rather than
forcing its own capture, because forcing one would open the second handle to a
single-reader device that this whole mechanism exists to prevent. The response
carries `coalesced`, which also gives capture_in_flight() the consumer its
camera.py counterpart has in the Diagnose tool, and the Test button says
"shared with a capture already running" instead of a bare success.

Tests 12 -> 20: an unexpected error reported as a failed capture, a raising
leader whose follower still gets a frame, the three coalesced states, and
redaction on each log line that can carry a URL. The raising-leader test
patches _capture_rtsp_frame rather than _capture_frame_uncoalesced, since a
stand-in installed in the latter's place sits above the catch and would test
the wrapper against a shape it can no longer be handed.
2026-07-31 08:47:57 +02:00
maziggy ac3e3cc60f fix(printers): don't retract a fan kit on a partial airduct frame
device.airduct is pushed field by field - the modeCur handler reads it with
an "in" check for that reason - so a frame can carry parts without carrying
every fan. Absence in that list is what tells us a kit is not fitted, and
taken from a truncated frame it made both accessory badges vanish mid-print
and started rejecting fan=aux2 on a printer that has the fan.

A parts list now counts as a full inventory only when it carries ids 1 (part
cooling) and 2 (aux). Neither is optional on a machine that reports an
airduct at all, and both appear in every layout in the support-package
archive - P2S base 1,2 / P2S+kit 1,2,3 / X2D 1,2,3,10 / H2C,H2D,H2S 1,2,3,6.
Anything narrower is a diff frame: its speeds are applied, presence is left
alone. Presence can still be added from a partial frame; only retraction
needs the full list, so a kit that really is removed still disappears.

Also compose showChamberFan from both model lists rather than branching
between them, so the P2S/X2D entries in MODELS_WITH_CHAMBER_FAN stay
reachable instead of reading as dead, and note in the fan-speed docstring
that the aux2 gate also rejects between connect and the first airduct push.
2026-07-30 18:04:38 +02:00
maziggy db538e43f1 fix(slice): give the slice modal one filament row per project slot (#2712)
The filament list is positional from the modal down to the CLI's
filament_N.json parts, but for a source that already carries slice_info the
requirements endpoint returns only the slots the plate consumes. A
MakerWorld model declaring four filaments and painting with slot 4 alone
therefore showed one dropdown, whose PETG pick the CLI bound to slot 1 —
slot 4 sliced with the profile baked into the source, and the print came out
PLA.

The endpoint now takes full_slots, which widens that answer to every
project slot with used_in_plate flags, and only the slice modal passes it.
Print-time AMS matching shares the endpoint and keeps the used-only list, so
it still asks for exactly the spools the job needs.
2026-07-30 17:34:26 +02:00
maziggy 83142c726c fix(slice): report a finished slice once, not once per queued poll
setInterval does not await an async callback. Slicing a large project
blocks the backend for seconds, so poll ticks piled up behind one stalled
request, each holding a snapshot taken while the job was still active.
They resolved together, and every one of them ran the completion path —
one toast and two query invalidations each. A 20s stall against the 1.5s
interval produced 13 "Sliced X" toasts from a single slice.

Only one poll round is now in flight at a time, which also stops queueing
requests against a backend that is already saturated. Completion is
recorded once per job id, and a round still awaiting a response when the
effect tears down now returns instead of acting.
2026-07-30 17:14:07 +02:00
maziggy 9ef06449ef fix(filament): a unique preset match no longer counts as a colour match (#2687)
The Filament Mapping panel reported "(Ready)" with a green tick for a slot
where the slice wanted dark red and the auto-matched tray held dark green.
Manually picking that same tray reported the mismatch correctly, which is
what made it obvious something was inconsistent.

Auto-match ranks candidates by tray_info_idx first, and a uniquely-matching
preset was accepted as definitive on the premise "same preset = same spool =
same colour". The preset names the variant, not the spool: GFA00 is PLA
Basic, GFA01 PLA Matte, GFA17 PLA Translucent, in every colour Bambu sells.
The reporter's own bundle has eight GFA00 trays in eight colours. With one
Matte spool loaded, every Matte requirement idx-matched it and the colour
comparison was never reached - which is why this surfaced on PLA Matte and
not on Basic, where several spools are usually loaded and the match falls
through to the branch that does compare colours.

The verdict now comes from the tray that was selected rather than from which
rule selected it, and both branches share one comparison so they cannot
drift apart again. Selection is unchanged - the right variant still wins per
mismatch and the slot stays selected.

A requirement with no colour at all is treated as satisfied rather than
mismatched; 3MFs that omit it parse to "" and there is nothing to disagree
with. That also affects the manual branch, which used to flag it.

No dispatch change: _get_missing_force_color_slots already required an exact
colour, so force colour match was gated correctly throughout.
2026-07-30 09:26:46 +02:00
maziggy 9ef03067f7 feat(file-manager): show last activity on folder rows via the existing date toggle (issue #2680)
Follow-up to #2680: the calendar toggle only put dates on the file pane, so
the folder tree had no way to show the timestamp it was already sorting on.
FolderTreeItem now takes showModified and renders latest_activity_at under
the folder name, threaded through the recursive call so nested folders get it
too. No backend change - the field was already on the wire from the sort fix.

Folders are labelled "last activity", not "last modified", and get their own
i18n key. The value is the newest timestamp among the folder, its files and
everything below it, so a folder can read as newer than its own directory
mtime - calling that "modified" would look like a fresh instance of the
ls -lt mismatch the issue was originally about. Folders with no activity
render nothing rather than an Invalid Date placeholder.

The name span moved into a flex column so the second line does not disturb
the row's link badge, file count or kebab menu. That broke a folder-delete
test that reached the row via parentElement, now fixed to use closest().

Separately, the #996 collapse describe left an implementation on the
module-global localStorage.getItem mock, which silently collapsed the folder
tree for every describe after it. It resets in afterEach now; without that,
any later test asserting on nested folders fails for reasons unrelated to
what it is testing.
2026-07-30 09:09:47 +02:00
maziggy 24322c71cb fix(tab-progress): drop the redundant status poll and quieten the test suite
The hook is mounted globally in WebSocketProvider, so refetchInterval on its
per-printer status queries added one request per printer every 30s on every
page. The Printers page already runs that fallback on the same query key, and
useWebSocket writes ['printerStatus', id] straight into the cache, so the poll
bought nothing outside the Printers page and cost a request per printer per
tab everywhere else.

Also captures document.title at mount instead of restoring to a hardcoded
'Bambuddy', so the default no longer has to be kept in sync with index.html.

jsdom has no canvas backend, so getContext('2d') logged a "Not implemented"
jsdomError with a full React stack on every run of the hook's tests, and the
favicon branch bailed on the null context and went untested. Stubbing
getContext/toDataURL removes the noise and lets the ring code run, so the
favicon swap and the restore-on-toggle-off path are now asserted.
2026-07-29 14:38:18 +02:00
maziggy 6cda236dce feat(notifications): optional Telegram forum topic via message_thread_id (#1518)
Telegram groups with Topics enabled always received notifications in the
General topic, since only Bot Token and Chat ID were configurable. Splitting
notifications per printer meant running a separate chat for each one.

The Telegram provider now takes an optional Forum Topic ID - the last number
in a topic's link, t.me/c/1234567890/25 - and routes its messages there. Left
empty, nothing changes.

The value is coerced to an int once in _send_telegram and attached to both the
sendMessage JSON body and the sendPhoto form data. That ordering matters:
Telegram rejects a string message_thread_id in the JSON body while accepting
one in the multipart call, so passing the raw form value through would have
worked for thumbnail notifications and 400'd for plain-text ones. A
non-numeric value is rejected in the form and again server-side before any
request goes out.

No migration - provider config is a JSON blob.

Adds Forum Topic ID plus help text to the Telegram section of the provider
dialog, translated in all 13 locales. Backend tests cover omitted / blank /
int-typed / non-numeric values and both send paths; frontend tests cover the
field being optional, absent for other providers, round-tripping on save, and
blocking save on a bad value.
2026-07-29 14:10:38 +02:00
maziggy c11bcecfd8 fix(i18n): list Ukrainian after Russian in the language picker
The uk locale was inserted before ru in i18n/index.ts - in the import block,
the resources map and availableLanguages. Locales are appended to those lists
as they land (tr, then ru on 2026-07-19, then uk on 2026-07-28), and
SettingsPage renders availableLanguages in array order, so the picker showed
Ukrainian above Russian while every other entry followed the order it was
added.

Moves uk to the end of all three lists. SUPPORTED_LNGS is sorted
alphabetically and already had uk in the correct position, so it is unchanged.

Frontend-only, no behaviour change beyond the picker's row order.
2026-07-29 13:22:24 +02:00
maziggy 91269f14fe fix(mqtt): report why a printer refused the connection instead of looping silently
A printer with a wrong access code gave no explanation anywhere. The connect
callback's failure branch was a bare `state.connected = False`, discarding the
CONNACK reason code the printer had just sent, so the only trace was paho's
follow-up disconnect -- logged every 30 seconds as "rc=Unspecified error",
which is exactly what a powered-off printer produces. In the report behind this
fix one of three printers had been in that loop for the whole capture, and
neither the log nor the support bundle could say why.

Bambu speaks MQTT 3.1.1, whose CONNACK return codes 4 and 5 paho maps onto
reason codes 134 and 135. Both are now logged with the printer's own reason
string and, for those two, the remedy: the access code is regenerated whenever
LAN Only or Developer Mode is toggled, so it has to be re-read from the screen.
The access code itself is never logged -- it would land in every bundle.

The reason is kept on the client as a stable slug and plumbed through
test_connection into the connection diagnostic, which now distinguishes two
cases it previously conflated. "The printer refused our credentials" is
asserted only when the printer said so; when all Bambuddy knows is that there
is no session, the text hedges and names the alternatives (rebooting, or
already at its limit of simultaneous connections). The old wording claimed the
access code was most likely wrong in both cases.

Frontend needed no change -- ConnectionDiagnostic already renders
`<status>_<reason>` variants with fallback to the plain per-status text, so an
unrecognised slug degrades to today's wording rather than a missing key.
2026-07-29 09:02:39 +02:00
maziggy c3cee54c0c fix(i18n): bring the Ukrainian locale up to parity and drop two English strings
The Ukrainian locale was authored before #2622 landed and merged dev in
without picking up the five slice.designSettings* keys that came with it, so
check:i18n failed at 5675 leaves against en's 5680. This is invisible on the
PR because ci.yml only triggers for pull requests targeting main, and this one
targets dev - the only checks that ran were the security workflow's.

Adds the five missing keys, following the file's own convention of rendering
"designer" as "автора" as it already does in slice.useEmbeddedHint.

Two values were still English and the parity gate could not see them, because
check 4 only fires on leaves byte-identical to en: profiles.localProfiles.
pressureAdvance and inventory.paProfileTab both read "Pressure Advance (PA)"
where en has "Pressure Advance" and "PA Profile". The added "(PA)" was enough
to walk past the equality test. Both are now translated, the second as a tab
label matching the Russian. Inline mentions of the term in profiles.subtitle
and kProfilesDescription stay in English, which is what ru does too.

failureDetection.mlUrl is translated rather than allow-listed, and the dead
'{{weight}} г' entry is dropped from UK_COGNATES - that list matches against
the English value, so an entry spelled in Ukrainian could never fire, and
since the unit is now localized no entry is needed at all.

Four straight apostrophes normalized to the typographic form used by the
other 186 in the file. Locale count in the README and the two wiki pages
corrected from 11 to 13; it had already been wrong, omitting Russian.
2026-07-28 15:27:04 +02:00
maziggy 8551e32f14 feat(slicer): keep the designer's print settings when re-slicing for another printer (#2622)
Published models often deviate from the stock Bambu profile on purpose -
five walls, 100% infill, a 0.1mm first layer. Re-slicing one for a
different printer discarded all of it: the picked process preset
overrides the file's embedded settings, and that override is precisely
what makes cross-printer re-slicing work, so it cannot just be dropped.
"Slice as designed" (#2611) does not help - it is all-or-nothing and
only offered when the picked printer already matches the design's
target.

The deviation list does not have to be computed. Bambu Studio writes it
into the 3MF as different_settings_to_system, laid out as
[process, *filaments, printer] - verified against real files at 2, 3 and
4 filament slots. The parser refuses any file whose array length
contradicts its own filament count rather than guessing an index, since
reading the printer slot as the process slot would carry the designer's
machine_start_gcode onto a foreign printer.

The slice dialog now lists exactly which print settings the author
changed and what each was set to, with a checkbox per setting. Design
intent - wall count, infill, layer and first-layer height, supports,
seam, brim, ironing - is ticked by default. Printer-specific values -
speeds, accelerations, jerk, fans, temperatures, prime-tower geometry -
are listed with a badge but start unticked: tuned for the author's
machine, they can be merely wrong on the target or outside the range its
profile accepts, which fails the slice outright.

Only ticked keys are sent, and only keys the source actually flags as
changed are applied. Values are written into the outgoing process JSON,
the same mechanism the support carry-over has used since #1881: for a
Standard preset pick that JSON is an inherits stub, so the patch is the
child in the chain and wins over the flattened parent. Process slot
only - filament picks are honoured as chosen.

The wiki's "this is not a settings merge" note under Slice as designed
described the gap this closes; rewritten to point at the new panel.

Translated in all locales; wiki updated. Covered by backend and frontend
tests.
2026-07-28 14:49:50 +02:00
maziggy 4e46ba071f feat(printers): show remaining time, ETA and layers on the size-S card (#2674)
Size S exists for one job: watching a whole fleet on a single screen. It
rendered the printer name, a status pip and a progress bar - every other
block on the card is gated behind the expanded view - so it could not
answer the question that view is for, "which printer finishes first".
Dropping to S to fit more printers meant losing the information you
dropped down to compare.

The compact card now carries one line of metrics under the progress bar
while a print is running: remaining time, ETA in the configured
12/24-hour format, and layer progress. These are the values the Medium
card already shows, rendered with the same formatters and the same ETA
styling so the two sizes read alike. Each value is omitted individually
when the printer does not report it, and the row holds its height when
nothing is printing so cards do not shift as prints start and finish.
Card dimensions and grid density are otherwise unchanged.

Frontend only. Wiki updated. Covered by tests - which required teaching
the new test file to mock localStorage.getItem, since the harness
replaces localStorage with bare vi.fn() stubs and setItem is a no-op;
without that the page falls back to its size-M default and the compact
branch never renders.
2026-07-28 14:28:07 +02:00
maziggy 8fd1f884dc feat(mqtt): publish the plate-clear gate and add a notification for it (#2525)
When a print reaches a terminal state Bambuddy holds the queue until
someone confirms the build plate is clear. That gate was visible only in
the Web UI: the printer's own MQTT push reports nothing beyond RUNNING,
PAUSE, FAILED, FINISH and IDLE, so an external automation could not tell
"finished" from "finished and still waiting for a human".

The per-printer status topic now carries an awaiting_plate_clear field,
and every transition is additionally published on a new retained topic,
bambuddy/printers/{serial}/plate_clear. Retained, and published from the
flag itself rather than from printer telemetry: a subscriber learns the
state of every printer the moment it connects, and the state stays
correct after Auto Off powers a printer down - telemetry stops there,
which would otherwise leave the status topic frozen at false.

Publishing is edge-triggered. The queue clears the gate on every
dispatch whether or not it was up, and no subscriber should see a
"plate cleared" for a plate that was never dirty. Persistence and the
WebSocket broadcast stay unconditional; they are idempotent and predate
this.

A matching Plate Clear Required notification event was added, off by
default on every provider because it fires after every print at the
same moment as the print-complete alert. Only the rising edge notifies.
Acknowledging still goes through POST /printers/{id}/clear-plate.

Two tests in test_printer_manager_status_broadcast.py asserted
_schedule_async.call_count == 2 for the setter. The new emission makes
it three on a transition, so they now assert that the persist and
broadcast coroutines are actually scheduled - which is the contract

Translated in all locales; wiki updated. Covered by backend and
frontend tests.
2026-07-28 13:36:55 +02:00
maziggy f4f76e0121 fix(inventory): allow editing and duplicating stock spools without a slicer preset (#1905)
A spool created by Quick Add, a CSV import or an RFID scan has no slicer
preset, brand or subtype. Reopening it in Edit Spool demanded all three
before anything could be saved, so changing its storage location, cost
or notes was impossible - and Copy Spool had the same gate with no Quick
Add toggle to waive it. The preset you were then forced to pick auto-
filled material, brand and subtype from the preset name, silently
rewriting a hand-entered manufacturer (Elegoo -> Generic) so the spool
no longer appeared where it had been filed.

Editing and copying now require only what the backend requires: the
material. Preset, brand and subtype stay fully visible and editable -
nothing is hidden the way Quick Add hides it - and the required-field
markers no longer advertise a rule that isn't enforced. Selecting a
preset fills only fields that are still empty or that a previously
selected preset had filled, so values the user (or the saved spool)
provided survive; switching between presets still replaces what the
earlier one contributed.

The brand and material dropdowns also no longer filter themselves down
to the brand/material pairs known to the color catalog and slicer
presets. Elegoo is catalogued only for PLA, which made a real product
like Elegoo ASA look impossible to enter. Both lists now always offer
everything known, with paired entries ranked first under Suggested and
the rest under All, and a spool's own custom brand or material is always
present in its own dropdown. The SpoolBuddy write-tag form shares these
fields and gets the same treatment.

Lastly the Quick Add layout no longer leaks out of create mode: quick-
adding a spool and then opening Edit left the edit form in the reduced
layout with no toggle to leave it, because the toggle is create-only.

Frontend only. Translated in all locales; wiki updated. Covered by
validation and form-interaction tests.
2026-07-28 13:09:16 +02:00
maziggy af7874546a feat(projects): per-file print progress and complete-sets tracking (#1897)
Projects made of many distinct files that each need N prints (e.g. 13
plates x 10 sets = 130 prints) only had aggregate progress. Finding out
"how many times have I printed plate_7?" meant reading the Activity
Timeline line by line, unusable at 130 events.

Projects now take an optional Copies per File target. Every printable
file in the project's linked folders shows an X / N badge with a mini
progress bar (gray not started, amber in progress, green done), and the
progress card gains a Complete Sets bar - the minimum per-file count,
i.e. how many finished assemblies can be shipped right now. Without the
target, printable files show a plain printed-count badge.

Counting matches the aggregate project stats: completed runs only,
served by a new /projects/{id}/file-progress endpoint. Runs attribute
to a file via a new library_file_id stamp on queue-dispatched archives,
falling back to content hash and then filename for historical rows.

Also fixed: files queued from a project-linked File Manager folder now
inherit that project, so their prints count toward project statistics -
previously only prints started from the project page were attributed.

Test-harness fix along the way: the test suite's get_db override never
committed, unlike production get_db, so endpoints relying on the
request-scoped commit silently lost their writes in tests. The override
now mirrors production commit/rollback semantics.
2026-07-28 12:46:31 +02:00
maziggy 1fb6978ee1 feat(library): let users delete empty folders (#1781)
Library folders have no ownership tracking, so folder deletion was
gated entirely behind library:delete_all - a user with
library:delete_own could create folders and delete their own files,
but the emptied folder sat there until an admin removed it.

Users with library:delete_own can now delete folders that are truly
empty: no subfolders and no files, including trashed ones - folder
deletion cascades, so removing a folder that holds another user's
trashed file would silently break trash restore. External folders
(operator-configured mounts) and folders linked to a project or
archive still require library:delete_all even when empty, since
deleting them affects more than the folder itself. The bulk-delete
endpoint applies the same rule instead of skipping all folders for
non-admin users.

The folder tree's Delete entry enables accordingly and shows a
"You can only delete empty folders" hint on non-empty folders. The
backend stays authoritative - a folder that only contains trashed
files is invisible in the tree but still refuses deletion.
2026-07-28 12:05:00 +02:00
maziggy eae5359fbc feat(printers): show AI failure detection state on printer cards (#1546)
The live Obico classification was only visible under Settings ->
Failure Detection, so tracking how detection matched an ongoing print
meant flipping between the Printers screen and Settings.

Each printer card's badge row now shows an AI badge whenever detection
is enabled for that printer, like the other health badges: gray Idle
while no print is being watched, then green Safe, amber Warning, or
red Failure while a print is actively monitored. The tooltip carries
the current smoothed score; clicking jumps to the full detection
status and history in Settings. Printers excluded from the monitored
subset show no badge.

Served by a new lightweight /obico/printer-status endpoint readable
with printer permissions alone - it exposes only the enabled flag, the
monitored-printer set, and per-printer classification, keeping ML URL
and other configuration behind the existing settings-gated endpoint.
2026-07-28 11:37:36 +02:00
maziggy 62ba751278 feat(notifications): Bark notification provider (#1495)
Bark is the open-source, account-free iOS push app (self-hostable
via bark-server). Configure with just the device key from the app;
the server URL defaults to the official api.day.app relay and
accepts a self-hosted instance. Optional settings: notification
Group, Sound, and iOS Interruption Level - Time Sensitive breaks
through scheduled summaries, Critical bypasses Silent mode and
Focus, Passive delivers silently.

bark-server can wrap failures in an HTTP 200 body ({"code": 400}),
so the sender checks the body code as well as the HTTP status.
Unknown interruption levels are dropped rather than forwarded.
2026-07-28 10:58:08 +02:00