Commit Graph
775 Commits
Author SHA1 Message Date
maziggy e6842e1d3c Rank near-colour matches by how they look, and share one filament type table (#2804)
Three follow-ups to #2804, all bearing on one decision: which spool a print
uses when the exact colour is not loaded.

Colour ranking is now perceptual. The ranking added in #2804 measured RGB
distance, which rates a colour by how far apart the numbers are rather than
how far apart they look, and it overweights blue badly enough to invert the
answer: against a required #1E4821 green, a purple #38202F is the nearer of
two eligible spools by RGB and four times the further once measured properly.
Both sides now use CIEDE2000 -- perceptual_color_distance in
backend/app/utils/color_utils.py and colorDistance in amsHelpers.ts, kept
structurally identical so they can be read side by side. Verified against the
Sharma/Wu/Dalal published reference set, all 31 pairs to 1e-4, and the two
implementations agree to within 1e-9 across 800 sampled pairs. Eligibility is
untouched, still the per-channel RGB box, so this only reorders spools that
already qualified.

Type matching now agrees between the interface and the scheduler. Bambu
firmware treats PA-CF, PA12-CF and PAHT-CF as one material and the scheduler
has always matched them accordingly, but the interface compared raw type
strings and called that same pairing a mismatch. The badge contradicted what
the printer was about to do, and the manual override picker, which groups by
canonical type, offered the very spool the badge then rejected. The fifteen
comparison sites in useFilamentMapping.ts, useMultiPrinterFilamentMapping.ts
and PrinterSelector.tsx now call filamentTypesCompatible.

The pipeline pre-flight reads the matcher's table instead of its own copy.
That copy had drifted into disagreeing in both directions: it aliased PLA
Basic to PLA where the matcher never has, so a run could clear the check and
then fail to map its slots, and it lacked the nylon grouping, so it flagged
runs the matcher handles without complaint. A check whose job is to predict
dispatch is wrong whenever it disagrees with dispatch, whichever way it leans,
so it and the scheduler now both read backend/app/utils/filament_types.py.

That canonicaliser deliberately does not strip surrounding whitespace. It
looks like a free improvement, but it would collapse a junk tray_type to ""
just as a 3MF declaring no filament type yields "", and a typeless requirement
would start matching a junk-typed tray instead of reporting the slot unmapped.
Padded type strings are worth handling on their own terms, with that case
addressed.

One behaviour change outside the ranking: the pre-flight is stricter for a
printer reporting a product name such as "PLA Basic" where the generic
material belongs, which it now flags rather than passes. Rare in practice,
since the printer reports material and product name in separate fields, and it
is the answer the matcher would give. Nothing about which spool a print
actually uses changed outside the colour ranking itself.

Adds 203 backend and 6 frontend tests. The #2804 tie-break test now uses
identical colours: two colours at equal RGB distance are not perceptually
tied, which is rather the point.
2026-08-13 12:04:56 +02:00
maziggy 4a65abe228 Tell the browser which colour scheme the page is in
The parts of a form control the browser draws itself -- a number input's
stepper, a date field's calendar button and popup, a select's dropdown,
scrollbars, the autofill tint -- were painted in the light appearance on
every theme. Bambuddy switches theme by swapping CSS variables under a
`dark` class, which the browser cannot see, so it assumed the page was
light and matched the steppers to a white background that was not there.

Declaring color-scheme alongside the variables fixes all of them at once,
in both directions. It goes on `.dark` rather than the per-palette
classes because the kiosk sets `dark` on the root element directly.

Three date and time fields had been pinned to dark by hand to work
around this and no longer need to be; being pinned, they were wrong
under the light theme anyway.
2026-08-11 12:16:31 +02:00
maziggy df5aa04df1 Pool AMS backup spools in the print dialog's filament check
The dialog weighed each plate against the spool in the slot it mapped to
and knew nothing about AMS Filament Backup, so a two-plate job needing
1441 g of ABS was refused against a 1000 g spool while the identical full
spool in the next slot went uncounted. The dispatcher has pooled matching
spools since #1762 and would have run the print -- "Print anyway" was
always the right answer to this warning.

The rule for which spools back each other up now lives in one place:
build_slot_materials() in filament_deficit, which the dispatcher's pool
and the new slot_materials half of GET /printers/{id}/inventory-remain
both draw on. The dialog groups on the keys it is handed rather than
resolving spools a second time, which is what let the two answers drift
apart, and which also gives the check to Spoolman users -- it read the
internal inventory only, so in Spoolman mode it approved everything.

Where a pool really is short the warning quotes the pooled totals, since
the per-slot figure reads as a contradiction next to a full peer spool.
2026-08-11 11:32:17 +02:00
maziggy e93f1b43c3 chore(settings): drop the Slicer Bundles notice and rebalance the columns
Bundle import was withdrawn in 0.2.5 and the panel was kept behind as a
static notice pointing at the alternatives. It has been on screen for
several releases, it was shown to everyone running the slicer sidecar
whether or not they had ever imported a bundle, and it was a card in
Settings -> Queue & Dispatch that could not be acted on. Component,
render site and all thirteen locales' strings are gone; no slicing
behaviour is touched.

Four docstrings still described the removed feature as a live fallback:
SliceModal was said to fall back to "the user's uploaded Slicer Bundles"
when a preset carries no compatible_printers. There is no bundle model
and no bundle endpoint left -- the actual fallback is the @BBL <code>
printer-model registry, which is what SliceModal has been doing since

Removing the card left the left column of that tab noticeably longer, so
G-code Injection moves to the foot of the right column. The card is
unchanged and keeps its card-gcode anchor, so settings search still
jumps to it.
2026-08-11 09:57:20 +02:00
maziggy b63adb8151 fix(virtual-printer): wrap the card header instead of overflowing it (#2808)
Every item in the collapsed header was flex-shrink-0, so the row was as
wide as its contents and the Card doesn't clip -- the remote-interface IP
and the enable toggle painted outside the card border. The name's
`truncate` couldn't save it: a flex item defaults to min-width:auto, so
it never shrank below its text (`flex-shrink-0 truncate` on the target
name was self-cancelling for the same reason).

Move the metadata into a flex-1 min-w-0 flex-wrap group so it wraps to a
second line, and keep the chevron, dot and toggle outside it. Wrapping
rather than truncating: the bind and remote-interface addresses are what
the page exists to show.

Needs three things at once, hence the report -- both IPs set (Bambuddy
and printer on different subnets), a target named "Printer at <ip>" from
discovery, and the 3-column card grid.

overflow-hidden is scoped to this card, not added to Card: half the cards
in the app render menus that deliberately paint outside their bounds.
2026-08-11 08:45:33 +02:00
maziggy 57190b10ba Stop offering server-side slicing for STEP files
The Slice action appeared on .step / .stp and the endpoint accepted the job,
but neither slicer can load one from its command line -- both answer
"Unknown file format. Input file must have .stl, .obj, .amf(.xml) extension."
So the file was read, converted and uploaded before failing as "The input
model file to the slicer can not be parsed", which reads as a corrupt model
rather than an unsupported format.

The endpoint refuses a STEP up front with a message saying to export it as
STL or 3MF, and the Slice and pipeline buttons no longer appear on one.

Open in Slicer is unchanged and still hands STEP to the desktop application,
which opens it fine -- that was always the working path. isSliceableFilename
(desktop) and isApiSliceableFilename (sidecar) are now separate predicates so
the two cannot drift back together.
2026-08-10 13:09:24 +02:00
maziggy 08df660f6c Replace the embedded G-code viewer with the slicer's own renderer
Sliced files previewed through a vendored copy of PrettyGCode in an
iframe. It drew each move as a screen-space line -- a line has no
thickness in the scene, so it cannot occlude the layer behind it, which
is why prints came out stringy and shimmered where layers crossed. Being
a separate app in a frame, it could be neither themed nor translated, and
carried its own machinery for detecting a proxy refusing the embed.

Now built on libvgcode, the renderer OrcaSlicer draws its own preview
with, vendored from three-slicer (AGPL, same as us). It takes the THREE
namespace as an argument and imports nothing, so it runs on our 0.181
rather than the 0.160 its package pins.

The parser is ours; upstream renders its own kernel's output and ships no
G-code parser at all. Two things it has to get right, both found by
checking a real plate rather than assuming:

- BambuStudio does not use the OrcaSlicer/PrusaSlicer annotations. It
  writes "; FEATURE:", "; LINE_WIDTH:", "; CHANGE_LAYER" and
  "; Z_HEIGHT:", not ";TYPE:", ";WIDTH:" and ";LAYER_CHANGE". Reading
  only the latter showed a 52-layer print as 23,165 layers in one colour,
  because with no layer marker recognised every travel Z-hop split a
  layer and every segment took the fallback feature.
- It emits a tenth of its moves as G2/G3 arcs -- 706 extruding ones in a
  single plate. Ignoring them punched holes through curved walls and tree
  supports. Arcs with no X/Y are the helical travel lift and lay down
  nothing, so they interpolate as travels.

Four colour modes: filament (default, from the AMS slots the file was
sliced with), feature, layer height, line width. Speed, fan and
temperature are deliberately absent -- upstream derives those from
settings rather than the toolpath, and guesses dressed as measurements
are worse than an honest omission. The parser now carries the data to do
them properly later.

Legend entries are switches. Hiding removes the records before the mesh
is built rather than recolouring them: the shader packs colour into a
single float with no alpha, so there is no transparent to set, and
removal is the useful behaviour anyway -- a hidden support stops
occluding what it covered.

The scene is built once and only the toolpath rebuilds. Doing otherwise
constructed a new WebGLRenderer on every render, because the buildVolume
default is an object literal and so a fresh identity each time; browsers
cap live WebGL contexts and drop the oldest, which blanked the canvas
after a few interactions.

utils/framing.ts goes with the iframe, along with six now-orphaned
strings in all 13 locales. src/lib/vendor is excluded from eslint --
acting on findings in vendored code makes it impossible to re-copy on the
next upstream release.
2026-08-09 14:10:18 +02:00
maziggy 2d584e02a4 Render the previews properly instead of sketching them
Model preview
-------------
The camera distance came from `maxDim * 1.8`, which accounts for neither
the camera's field of view nor the viewport's aspect ratio, so a tall
narrow panel was framed as though it were square -- the model sat in the
middle with a screenful of dead space above it. Solved from the bounding
sphere against both fields of view instead, so it fills the frame at any
panel shape. Near/far now scale to the subject rather than staying at the
0.1/10000 defaults.

Lighting was two directional lamps over 0.6 flat ambient on a Phong
material: every surface facing the same way got an identical colour,
which is what flattened models into silhouettes. Now a MeshStandard
material lit by a generated RoomEnvironment through PMREMGenerator, with
ACES tone mapping so the lit side of a saturated filament colour doesn't
clip to white and drain the hue.

Added a contact shadow. Two things would have made it silently draw
nothing: the build plate is an unlit MeshBasicMaterial and cannot receive
shadows, so the catcher is a separate ShadowMaterial plane; and three's
default directional shadow camera is a +/-5 unit box, which nothing on a
256mm bed falls inside.

The PMREM render target is disposed on unmount -- it is GPU memory the
collector cannot reclaim, and this viewer is opened and closed repeatedly
from the file manager. Device pixel ratio is capped at 2; a 3x phone
screen was quadrupling fragment load for no visible gain.

G-code preview
--------------
Switched gcode-preview from `lineWidth: 2` to `renderTubes`. A 2px
screen-space line has no thickness in the scene, so it cannot occlude the
layer behind it -- hence the stringy surface and the shimmer where layers
overlap. Tubes are built from real extrusion width and height, so the
print occludes itself.

The flag is marked experimental upstream, and the 0.42 extrusion width is
a hardcoded default that is right for a 0.4 nozzle and wrong for a 0.6.
Both are worth revisiting if this holds up in use.

Modal
-----
Removed the G-code tab. G-code has its own full-page viewer, and a
preview of a model is a different question from a preview of a print.
That left dead weight behind it: the render branch, the GcodeViewer
import, the has_gcode capability (still computed, never read), the Code2
icon, and two orphaned strings in all 13 locales. One test was repurposed
to assert the tab is absent so it cannot creep back; two others only
exercised that tab's disabled state and went with it.
2026-08-09 12:00:11 +02:00
maziggy b6027b7138 Say why a preset's values are unavailable, not just that they are
The settings panel collapsed four causes into one message -- "the picked
preset's own values could not be read" -- with no indication of what to do
about it.

The overwhelmingly common cause has an obvious fix, and it isn't an edge
case: an install pulls its sidecar as SIDECAR_TAG:-latest regardless of
which Bambuddy channel it is on, so a current Bambuddy talking to a
sidecar that predates POST /profiles/resolve is the normal state, not a
misconfiguration. Those users would have seen an amber warning on every
slice with nothing pointing at the sidecar image.

resolve_profile now returns ResolvedProfile(values, reason) instead of
None for everything, the route passes the reason through, and the panel
picks its message from it:

  sidecar_outdated     -> name the fix: update the sidecar image
  sidecar_unavailable  -> the sidecar did not answer
  not_configured       -> no sidecar is configured
  preset_unresolved    -> the previous generic wording

A request that fails outright maps to sidecar_unavailable, since a
backend we cannot reach and a sidecar that will not answer are the same
thing from the dialog.

Every variant still ends with "anything you don't change still uses the
preset" -- that reassurance is the point of the notice, and it is true
whichever way the lookup failed.

Tests pin the distinction rather than just the happy path: a 404 and a
500 must produce different reasons, and each panel case asserts both that
its own message appears and that the "update the sidecar image" line does
not leak into the others.
2026-08-09 11:25:09 +02:00
maziggy f421bb8160 Show the picked preset's real values in the process-settings panel
The panel baselined every field on the option schema's compiled-in
defaults, so a preset setting a 0.42mm line width displayed 0 -- the C++
default meaning "derive from the nozzle". Every field was affected; the
Line width group just made it obvious.

Bambuddy cannot answer this itself. A standard-tier pick is only an
{inherits: ...} stub on our side, and local/cloud presets are deltas whose
remainder lives in the profile tree bundled inside the running sidecar.
The values now come from the sidecar's POST /profiles/resolve, which runs
the same resolver /slice does against the same profiles, so what the panel
shows cannot disagree with what a slice produces. Deliberately not the
local orca_profiles resolver: it walks OrcaSlicer's published tree, which
can differ from the image actually installed.

An untouched field shows the preset's value and reverting returns to it.
isModified compares against that baseline too, so fields the preset moved
off the C++ default are no longer flagged as user edits, and values nobody
typed are no longer sent. When the values can't be read -- sidecar offline
or older than the endpoint -- the panel falls back to schema defaults and
says so rather than presenting them as the preset's.

Row layout, from screenshots:

- The control column is anchored to the right edge at a fixed width. It
  had been packed left after a fixed label column, leaving the values
  stranded mid-container with dead space beside them.
- Units are no longer truncated to "mm o...". The cap fitted the common
  "mm" but not "mm or %" or "mm/s² or %".
- The "from file" tick moved ahead of the control it qualifies; it used to
  sit past the unit at the row's right edge, reading as unrelated.

Both the unit and the control keep fixed widths, and the tick's slot is
reserved on rows without one -- sizing any of them to content makes each
row's input land at a different x and the column comes out ragged.

Also fixes a field that could not be cleared: emptying a free-text input
dropped the key, so it snapped back to the baseline and retyping appended
to it ("0.42" + "0.5" = "0.420.5"). The number branch was fixed earlier;
the text branch -- coFloatOrPercent, coString, the vector types -- was
not, and the regression test used a number input so it never caught it.

Requires a sidecar built from orca-slicer-api 4b664b7 or later. Older
images 404 the endpoint, which is handled as the fallback above.
2026-08-09 11:02:03 +02:00
maziggy c384911f7c Sync 2026-08-09 10:00:53 +02:00
maziggy 2368445378 Changed layout 2026-08-08 16:51:19 +02:00
maziggy f0500578bd Edit the full print-parameter set from the slice dialog
Slicing from Bambuddy meant taking a process preset as-is; any change
meant a round trip through Bambu Studio. The slice dialog now carries
OrcaSlicer's full process tree -- pages, groups, labels, tooltips,
ranges and defaults extracted from the slicer's own sources.

Enable/disable rules are evaluated from the slicer's own enable_if
expressions via a recursive-descent interpreter (no eval, CSP), with
enum comparisons validated against each option's declared values.
Anything undecidable leaves the field editable rather than greyed.

Overrides apply after the source's support config (#1881) and the
designer's carried tweaks (#2622), so an explicit choice always wins;
an untouched panel sends the same request as before.

Adds slice_engine as a separate setting from preferred_slicer -- where
slicing runs is a different axis from which binary the sidecar drives.
Only the sidecar engine is registered, so no picker renders yet.
2026-08-08 16:33:41 +02:00
maziggy 328bac450a Stop auto-drying re-arming into a threshold it can never reach (#2770)
An H2D armed five 12-hour drying cycles inside four hours, one of them six
seconds after the previous one ended, and none ran more than a couple of
hours.

Two things combine. The firmware ends a cycle when it decides the filament
is dry rather than when the clock runs out, and reports no fault doing it --
across this printer's history the run length tracks how wet the spools were,
from nearly the full 12 hours starting at 32% down to minutes once the unit
sat at 10-13%. That part is the AMS doing its job.

The loop is ours. An AMS reports higher relative humidity while it is warm
than once it has cooled: the same unit read 10-13% cold and 15-20% through
every cycle. With the threshold at 14% the reading at the moment a cycle
ended was always still above it, so the next 30-second pass armed another
12-hour cycle. Nothing counted, nothing waited, and it only stopped when the
box finally cooled enough to read 13%.

Auto-drying now waits 30 minutes after a cycle ends before arming another on
the same unit, and gives up on a unit after two consecutive cycles that
bring the reading no lower -- logging why and sending a new notification,
on by default because it reports that Bambuddy has stopped acting. Progress
is judged against the lowest reading any cycle on that unit has ended at,
not against the threshold, so a genuinely wet spool in a humid room coming
down 40-37-35 keeps drying however far it still is from the target;
comparing against the best so far rather than the previous end stops a
sensor wobbling by one point reading as progress every other cycle. The
suspension lifts by itself once the reading falls below the threshold.

Neither guard can stop a running cycle, and a cycle Bambuddy cut short for a
print, or that the user stopped by hand, is not counted against the unit --
so a farm that dries between queue jobs is unaffected. The threshold field
now warns below 20%, and every cycle end logs the unit's temperature and
humidity, which is what made this diagnosable.

The same bundle showed unrelated tasks failing with "database is locked",
each inside a 30.000-second Discord connect timeout. Alarms are raised from
inside the loop that records sensor history, at a point where the new rows
are added but not committed; the first read in the notification path flushed
them to satisfy itself, opening a write transaction, and the provider was
then contacted over the network with that transaction still open. SQLite
allows one writer and 30 seconds outlives the 15-second busy timeout, so
every other write in that window failed. The two reads that run before a
provider is contacted no longer flush the caller's pending work, and the
connect timeout is 5 seconds rather than 30 -- the body keeps the full 30,
so image uploads on a slow uplink are unaffected. SQLite only; Postgres has
no single-writer limit.
2026-08-08 12:40:17 +02:00
maziggy 04009a5c6a Post work PR #1448 2026-08-08 11:42:44 +02:00
MartinNYHC 406cf71149 Merge branch 'dev' into feature/billing 2026-08-08 10:56:09 +02:00
maziggy 82d6d98266 Restore the pointer cursor on interactive controls (#2791)
Hovering most of Bambuddy gave an arrow rather than a hand. Not everywhere,
which is what made it read as sloppiness rather than a bug: the update pill was
inert while the buttons beside it were fine, a bed or nozzle tile responded but
the history-graph button in its corner did not, and dropdowns went either way
with no pattern behind it.

The pattern was there. Tailwind v3's Preflight set `button { cursor: pointer }`.
v4 dropped it to match the browser default, which for a button is `default`.
Bambuddy has been on v4 since the frontend was built, and `src/index.css` never
had a base layer restoring it, so a button only looked clickable where someone
had written `cursor-pointer` by hand. 15 of 934 had. 0 of 149 selects, and 19
of 130 checkbox/radio inputs. The 233 ad-hoc `cursor-pointer` usages are why it
looked arbitrary instead of uniformly broken.

One `@layer base` rule now covers button, select, checkbox, radio, summary and
[role=button]. base sits below utilities, so `cursor-not-allowed` and the
`disabled:cursor-*` variants still win; the `:not(:disabled)` guard catches the
disabled controls that carry no such utility. Verified against the built bundle
rather than the source -- the rule lands inside @layer base, and
`.cursor-not-allowed` is emitted after it.

Click-outside backdrops are deliberately excluded. 90 of the 96 remaining
onClick divs are `fixed inset-0` overlays; a full-screen sheet advertising
itself as a button is worse than one that says nothing. Of the rest, 50 are
stopPropagation wrappers and 3 are the temperature tiles, which already set the
cursor through `statusControlClass` -- which is exactly why those tiles worked
while the button nested inside them did not. That left two real ones: Card, now
conditional on an onClick actually being passed, and the queue card, whose
existing `sm:cursor-default` kept the desktop intent.

Separately, from the same report. FilamentHoverCard draws the slot menu twice,
and the two paths had drifted into opposite orders: Configure above Assign Spool
on an empty slot, the reverse on a filled one, so the menu reshuffled itself
depending on whether the slot held filament. Both now lead with the spool
action. Tests assert the order on each path, so one can no longer move without
the other -- checked by reinstating the old order and confirming the empty-slot
test fails.

Those buttons also used justify-center, which centred each label independently
and left the icons in a ragged column; they are justify-start now. Their hover
was a 10% opacity step that was very hard to see, now 20%. And the favourites
star previews yellow on hover, suppressed when the user lacks archives:update.
2026-08-08 10:06:45 +02:00
behrinml a9e23910fc Merge branch 'dev' into feature/billing 2026-08-06 20:32:59 +02:00
behrinml dd1d40b0d4 implemented pr (worth fixing) feedback
update commit
2026-08-06 20:27:25 +02:00
maziggy 306b9ba7fd Accept Forgejo tokens scoped to a single repository (#2775)
ForgejoBackend.test_connection asked GET /user who the token belonged to
before asking whether the token could reach the repository, and treated a 403
there as fatal. A Forgejo v15 repository-scoped token may only carry
read/write on issues and repositories, so it 403s on /user -- and was rejected
despite reaching its own repository fine, which is all a backup needs: the push
path uses the Contents API and restore reads commits, trees and blobs, all
under /repos/{owner}/{repo}. That /user call was the only one in the whole
provider layer.

The probe stays, because a 401 from it is genuinely conclusive and names a bad
token before the repo call has to guess -- Forgejo v15+ hides a private repo
behind 404 rather than 403, so the repo call cannot always tell those apart.
Every other status now falls through to the repo check.

Two additions keep the messages as sharp as before: the repo call's own 401 is
mapped to "Invalid access token" instead of a generic API error, and the 404
names write:repository and the scoped-to-another-repository case, mentioning a
possibly-invalid token only when /user did not confirm the identity.

The token hint under the field was one shared string reading "fine-grained
token with Contents read/write" -- GitHub's advice, shown to Gitea, Forgejo and
GitLab users too. It is now per provider via PROVIDER_TOKEN_HINT_I18N_KEY,
following the existing repo-URL placeholder map, translated in all 13 locales.

Tests pin the repository-scoped token connecting, a transient /user status not
blocking the repo call, both 404 wordings, and the repo-call 401; a frontend
test switches providers and asserts the hint follows.
2026-08-06 12:08:36 +02:00
maziggy b5163b94f8 fix(backup): report the categories a failed restore already committed (#2656)
The service reports what landed on a part-way failure -- categories commit as
they finish, so results names the ones on disk -- and the modal gated the
whole result panel on success, so it showed the failure message and dropped
them.

The cache invalidation was inside that same branch, which is the half that
mattered: a run that committed the settings category and then failed left the
app rendering pre-restore settings, with no reload and no re-read, which is
the failure the modal's own reload-on-close exists to prevent.

Gate on what was written instead. A refusal that never reached a category
still carries an empty results and still keeps the form, so the mutex and
backup-in-flight cases are unchanged. A partial does not read as a success:
the tick becomes a warning and a line says the listed categories are the ones
on disk.

---

fix(backup): keep the local owner when the backup names one we cannot resolve (#2656)

An owner the backup names but this instance has no user for was written as
NULL, and overwrite is a blanket setattr -- so restoring over a local archive
that had a perfectly good owner took it away, which is the 404-for-its-own-
owner failure this column is carried across to fix. Resolving by username
widened the trigger from a stale id to any user renamed since the backup.

It is the same state as an absent key: the backup has not told us who owns
this. So it takes the same action -- the column is not written at all.
Overwrite keeps the local owner, insert lands ownerless with the note, and an
explicit null still writes, so overwrite still means "match the backup".

The notes move to the insert path with it. On overwrite nothing was taken
away, so there is nothing to warn about, which is the rule the absent-key
case already follows.
2026-08-06 08:46:02 +02:00
jmoore-skild bb25e36510 Merge branch 'dev' into feature/2656-restore-from-github 2026-08-05 16:47:49 -04:00
behrinml 43bf854bd5 Merge remote-tracking branch 'upstream/dev' into feature/billing 2026-08-05 20:19:32 +02:00
maziggy cd004df817 Show Home Assistant sensors on the printer card (#1148, #448)
Binds binary_sensor and reading-carrying sensor entities to a printer and
renders their state on its card, worded by Home Assistant's device_class.
Optional per-sensor alert condition drives a notification on the transition
into the alert state and an opt-in interlock that holds queued prints while
alerting -- a hold with a readable waiting_reason, never a failure, and only
ever on a sensor that was read successfully.

Sensors get their own table rather than a wider entity pattern on SmartPlug:
get_smart_plug_by_printer would otherwise hand the card's power button a door
contact to switch.

The hold is passed to the model matcher directly rather than merged into
busy_printers: _check_auto_drying reads that set as "is currently printing"
and would put an idle-but-held printer down the mid-print drying path.

The notification_providers migration spells its default FALSE, not 0 --
Postgres rejects an integer default for a boolean and _safe_execute swallows
the error.
2026-08-05 14:26:38 +02:00
maziggy fb4c130bb2 fix(slicer): drop the legacy library:read from the slice gate (#2725)
The desktop handoff accepted library:read alongside read_all/read_own, on
the reasoning that default groups do not carry it and requiring it would
lock out Operators and Viewers. The permission grants nothing in that
position: the slicer-token endpoint gates on
require_ownership_permission(LIBRARY_READ_ALL, LIBRARY_READ_OWN), and
neither that dependency nor User.has_permission expands the legacy name,
so a group holding only library:read gets a 403 there. It cannot reach
the File Manager to try, either - GET /library/folders gates on the same
pair - and the library:read -> library:read_own migration in
core/database.py runs only over the groups named in DEFAULT_GROUPS, so a
custom role that still carries it stays stuck rather than being upgraded.
custom role that still carries it stays stuck rather than being upgraded.

Accepting it only enabled a menu item the server refuses, and the failure
is indistinguishable from "no slicer installed" once the catch hands the
unauthenticated URL over. Removed, with the comment recording the reason
so the next reader does not re-add it, and a test that pins it.

---

refactor(slicer): share one sliceable-file-type rule (#2725)

The File Manager and the 3D preview decide the same thing about the same
file and each held its own list of extensions - which is how they came to
disagree, offering a desktop handoff for an STL whose own preview showed
"Open in Slicer" greyed out. Making the two lists identical fixed the
symptom and left the drift, so SLICEABLE_FILE_TYPES now lives in
utils/slicer.ts with isSliceableFileType for a stored file_type and
isSliceableFilename for a name.

The filename form still rules out the compound extensions explicitly,
since .gcode.3mf ends with .3mf; the type form does not need to, because
classify_file_type stores that one whole.

Both test files mocked the whole slicer module, which would have replaced
the new predicates with undefined - switched to importOriginal so only
openInSlicer is stubbed. That is the better shape regardless: the tests
now exercise the rule the component runs instead of a copy declared
beside them.

Carries the rebuilt bundle. The CSS hash moves with it - the split button
introduces Tailwind classes the previous build had no reason to emit.
2026-08-05 12:11:55 +02:00
Pascal Heidmann af7908ca1e Improve local slicer selection with code review suggestions, adding fail toast and handle (legacy) read permissions 2026-08-04 23:33:07 +02:00
jmoore-skild 0cf5b8da41 fix(backup): tell a restore apart from a backup in Backup History (#2656)
A restore writes a `github_backup_logs` row too — same table, same status
values, and it already carried `trigger: 'restore'`, which the API already
returned. The history table rendered date / status / commit only, so the row
read as a successful backup dated now while "Last backup" said something
else: `last_backup_at` is only stamped by an actual backup, and the two
disagreeing is alarming with nothing on screen to explain it.

Adds the Type column the trigger was always there to fill. Unknown values
fall back to the raw string rather than rendering blank, matching the
`backup.pathCheck.*` lookup a few hundred lines up — a trigger kind added
later shows up as itself instead of vanishing.

Backend unchanged: it has recorded this correctly since the restore path was
written.

Three tests, all three failing without the column. 13 locales in parity at
5776 leaves — pt-BR takes "Backup manual" rather than the parenthesised form
because "Backup (manual)" is identical to en, which the parity check counts
as untranslated.

Bundle rebuilt: `index-DhOfNgMz.js` → `index-CadgB7UN.js`. It also picks up
the `archivesOwnerUnknown` leaves from the previous commit, which changed
i18n without rebuilding.
2026-08-04 08:57:35 -04:00
jmoore-skild df6656a0a9 fix(backup): drop the settings-pin workaround, upstream fixed the cause (#2656)
This modal carried two workarounds for #2716: `onSuccess` deliberately did not
invalidate `['settings']`, and a query-cache subscription pinned the entry to
the pre-restore copy for as long as the result panel was up. Both existed
because SettingsPage's debounced auto-save diffed its `localSettings` form
state against the live cache, so any refetch of a restored settings row --
this modal's, a window refocus, a reconnect, or any of the ~30 other observers
of the key -- read as an edit and PATCHed the pre-restore values back over the
restore about 500 ms later.

`43cb216a` on dev fixed that. The page now keeps a server baseline and
reconciles a moved snapshot field by field: an untouched field adopts the
server's value instead of overwriting it. The restore no longer needs an
exception, and maziggy explicitly invited dropping it.

A commit on top rather than a rebase-drop of `21bb5afc`: later commits touch
this file, and the workaround was right when it was written. This says so.

The reload on close stays -- it was never one of the two workarounds. Its
stated reason was, though, and it was the #2716 bug, so it is restated for
what it actually buys: invalidating `['settings']` only resyncs what reads
that query, and the interface language, currency and auth toggles are read on
boot.

Tests: "never invalidates the settings query" inverts; the pin test and its
control go with the pin. The reload pair stays. 28 -> 26 tests in this file.

Bundle rebuilt: index-CCCWDEkl.js -> index-CHCEEMgx.js, carrying this, J1's
locale leaf and the reworded ack caveat. CSS hash unchanged.
2026-08-04 08:57:34 -04:00
jmoore-skild 27e97bc979 fix(backup): disclose the K-profile exception before the restore, not after (#2656)
With overwrite off, the confirmation said "Missing entries are added; existing
entries stay as they are." For archives, spools and settings that is true --
_apply threads the flag into all three. _restore_kprofiles takes no overwrite
parameter at all, and deliberately: writing a slot is always an overwrite on the
printer, so it resolves the live cali_idx and publishes extrusion_cali_set
either way, replacing whatever calibration that slot currently holds.

The behaviour is right and the backend does say so, but it says so as a
kprofilesAlwaysOverwrite note -- which only reaches the user in the result panel,
after an MQTT send that cannot be taken back. The one screen that explains
overwrite-off stated the opposite. So the fix is on the frontend, where the
mismatch is.

One new leaf, kprofilesOverwriteCaveat, in all 13 locales, rendered in two
places: appended to the overwrite-off confirmation when kprofiles is among the
selected categories, and beside the K-profiles row itself as soon as it is
ticked, which is the same screen as the toggle whose promise it qualifies.
Neither appears with overwrite on, where nothing is promising otherwise.

Tests: 2 that fail pre-fix (the caveat beside the row, and inside the
confirmation the user clicks through) and 2 controls (a spools-only restore keeps
the plain message; overwrite-on keeps the strong one and adds nothing). Frontend
suite 2601 -> 2605 tests across 195 files.

Bundle rebuilt for this and for H1's new preview detail string:
index-DPSSa0iw.js -> index-Dg68T-ox.js. CSS hash unchanged.
2026-08-04 08:57:34 -04:00
jmoore-skild 945f50a1ab fix(backup): stop overwrite writing a spool's other tag key (#2656)
Four of the review's smaller items.

E6, the substantive one. tag_uid and tray_uuid are both in the overwrite setattr
loop, so a spool matched on one key got the backup's *other* key written onto it.
Neither column has a unique constraint (models/spool.py, and no unique index in
the migrations), so nothing errors — a duplicate tag simply appears, after which
_find_spool's .scalars().first() is non-deterministic and an AMS tag lookup
resolves to an arbitrary one of the two spools. The same loop could also clear a
tag the user had scanned since the backup was taken, when the backup entry held
None.

_find_spool now reports which key matched, and _guard_tag_overwrite drops a tag
column from the write when the incoming value is empty and the local row has one
(the backup predates the scan, so the local tag is the newer fact) or when
another local spool already holds it. Announced in the tally the way the archive
un-delete case already announces itself, rather than done silently — the
spoolTagKept locale key landed with the rest of the i18n block last commit.

E5. The Restore button is hidden without github:restore. All three endpoints are
gated on it server-side, so the modal 403s on its first preview; offering the
button is offering an action that cannot work. Button only — the card stays
visible, since configuring backups is a separate permission — and hasPermission
returns true with auth off, so a single-user instance is unaffected.

E3. models/github_backup.py: the trigger comment said manual/scheduled; this PR
added a third value.

E4. ha_token_from_env: recommending no change, with the reasoning recorded as a
test rather than left in a review thread. It is built only in the settings GET
response, is absent from AppSettingsUpdate, and is therefore never a Settings
row — it cannot reach a backup, so an allowlist entry would be dead code. Worse,
a name-shaped exception to a belt-and-braces denylist is a live hole: an
attacker-authored settings/app_settings.json could get a *token*-named row
written by choosing that name.

4 unit tests and 1 frontend test that fail against this commit's parent, plus 4
controls: a free tag is still written, an unchanged tag is not reported as kept,
an insert is unaffected, and the button still shows with auth disabled.
2026-08-04 08:57:34 -04:00
jmoore-skild af8d14d796 i18n(backup): make the restore notes and preview details translatable (#2656)
A German user got a translated modal with "Not present in this backup commit" in
the middle of it. Every tally note and preview caveat was a server-built English
sentence rendered verbatim.

Follows the backup.pathCheck contract already in use one card down in the same
component, deliberately rather than inventing a second convention: the server
sends a `code` plus typed `params` and carries the English along as the
fallback, and the client renders
`t(`...${code}`, { ...params, defaultValue: message })`. The defaultValue arm is
what keeps a newer backend's unfamiliar code readable instead of printing the
raw key — covered by its own test.

Shapes:
- notes: list[str] -> list[GitHubRestoreNote] {code, params, message}. Breaking,
  but the field is unreleased in this same PR.
- GitHubRestorePreviewCategory gains detail_code / detail_params; `detail` stays
  as the English fallback.
- _CategoryTally.note(code, message, **params), deduped on (code, params) rather
  than on the rendered text, so two offline printers both keep their names. The
  20-note cap is unchanged.

28 new leaves across 13 locales: 20 notes.* and 8 details.*. `noData` collapses
the four per-category "No X data in this backup" strings into one, since the
category heading already renders beside it. Counts use single-form {{count}} in
the existing "N record(s)" style rather than i18next plural suffixes — nothing in
this block uses _one/_other and the parity script has extra rules for them.
Parity holds at 5737 leaves in all 13 locales.

Deliberately out of scope, and worth saying so rather than leaving it to look
like an oversight: result.message, the commit-picker subject lines and the HTTP
error strings stay English. Those also originate in the provider backends, so
code-ifying them widens the diff well past the restore service.

spoolTagKept is added here with the rest of the locale churn but is not emitted
until the next commit, so the 13-locale change lands once.
2026-08-04 08:57:33 -04:00
jmoore-skild 07244b6a43 fix(backup): don't count a stale selection, and say when archive links are dropped (#2656)
Two smaller restore-path fixes from the same review.

The modal's footer counted `selected` raw while the checkboxes rendered
`selected && isAvailable`. Switching commits keeps `selected` on purpose (it is
only pruned once the new preview lands), so for as long as the new commit's
preview was in flight — with the category list replaced by its spinner — the
footer still read "2 selected" over an enabled Restore button, and clicking it
restored the newly-picked commit with the previous commit's categories, none of
which the user had seen an item count for. The count and the POST body now come
from one `selectedCategories` memo gated on availability, exactly as the
checkboxes are, so both go empty until the preview lands.

Restoring Spool inventory without Print archives leaves archive_id_map empty,
so every usage -> archive link is nulled even where the archive exists locally.
It can't be resolved here (the archives payload isn't fetched for a category
that wasn't selected) and a later archives-only restore won't repair it either,
since the usage dedupe key doesn't include archive_id and those rows read as
already-present. So it gets a note naming the remedy while the user can still
redo the run with both categories ticked.

Carries the rebuilt bundle (index-C2LOlVCR.js -> index-C16HJNOV.js).
2026-08-04 08:22:58 -04:00
jmoore-skild 208170b03a fix(backup): don't let SettingsPage revert a restored settings category (#2656)
Restoring App Settings from the Backup tab silently reverted almost everything
it reported restoring.

GitHubRestoreModal invalidated ['settings'] on success. SettingsPage — which
renders the modal — keeps a `localSettings` copy of its form state alongside a
debounced effect that PATCHes it back whenever the server copy differs. The
refetch made the two differ, the effect cannot tell "server changed" from "user
edited", and 500 ms later it wrote the pre-restore values back over the restore.
75 of the ~80 keys in a backup sit in that save payload, so a restore reporting
"77 restored, 0 failed" left only the five auth/internal flags behind. The
backend was correct throughout: the same restore driven against the API with no
browser open applies cleanly.

Since the Restore button lives on the Settings page, this was the default path
rather than an edge case.

Fixed inside the modal rather than in SettingsPage: that debounce's own comments
show it was tuned to avoid resetting text fields mid-typing, and widening this
change into it risks that. So ['settings'] is no longer invalidated, and every
exit path (footer Close, header X, overlay click, Escape) now reloads instead of
closing when settings were among the restored categories, since leaving the page
mounted is what arms the overwrite. The ['spools'] and ['archives']
invalidations are unchanged — SettingsPage is the only page carrying this kind
of whole-payload auto-save.

The root cause is left for a follow-up: any future feature that writes settings
server-side will be reverted the same way.

Found by manual end-to-end testing against a private test repo, which also
confirmed the natural-key id remapping and the overwrite-off behaviour working
as designed.

Tests: 3 new frontend tests — the settings query is never invalidated, a
settings restore reloads rather than closing, and a non-settings restore still
closes normally. The first two fail against the pre-fix component. Full frontend
suite green (186 files / 2459 tests), i18n parity unchanged, eslint clean.
2026-08-04 08:22:57 -04:00
jmoore-skild 6a239314dc feat(backup): restore selected categories from a Git backup commit (#2656)
The Git backup feature was push-only: there was no equivalent of the local
backup's Restore button, so recovering meant hand-downloading JSON files from
the repository. This adds the read side.

Providers gain list_commits / list_tree / fetch_files on the GitProviderBackend
ABC. GitHub implements them against the Git Data API and Gitea/Forgejo inherit
that unchanged; GitLab overrides for its own REST shape, including tree
pagination and subgroup path encoding. fetch_files is batched so the path ->
blob SHA lookup happens once per restore rather than once per file, and uses the
blobs API rather than contents because contents silently inlines only the first
1 MB.

The new GitHubRestoreService resolves HEAD to a concrete SHA up front, so a
preview and the restore that follows act on the same commit even if a scheduled
backup lands in between. Categories are applied archives -> spools -> settings
-> kprofiles: archives first because spool usage history references archive_id,
K-profiles last because they leave the database and publish over MQTT.

Restores never reuse the backup's primary keys. spool.id and print_archives.id
are bare autoincrement columns, so ids from an old backup very likely belong to
unrelated rows today; rows are matched on natural keys (tag_uid, then
tray_uuid, then a descriptive composite for spools; content_hash or filename
plus started_at for archives), inserted without an explicit id, and an
old_id -> new_id map rewrites the foreign keys in spool usage history.
created_at is carried across on insert so restoring the same backup twice
matches instead of duplicating. Dangling printer/project links are cleared and
reported rather than failing the row.

Settings restore re-applies the collector's credential denylist on the read
side, plus a pattern guard, because a backup taken before that denylist existed
can still contain secrets. Restored archives are metadata-only: the 3MF and
thumbnail bytes are not in a Git backup and print_archives.file_path is NOT
NULL, so inserted rows get an empty path and the UI says so.

Backup and restore take a mutex against each other; both write the same tables
and talk to the same printers. Restores are logged as GitHubBackupLog rows with
trigger="restore", which needs no migration and surfaces them in the existing
History card.

Cloud profiles are deliberately not a restore category. The collector never
actually writes cloud_profiles/*.json - it reads a "setting" list key the Bambu
Cloud API does not return - and the preset list it would write carries no
setting payload. Filed separately.

Permission github:restore already existed and is granted to Administrators, so
no permission changes were needed.

Tests: 125 new backend tests (provider reads across all four providers, the
per-category appliers, the API endpoints) and 13 frontend tests. Full suites
pass with no regressions; the 35 backend failures on Windows are byte-identical
with and without this branch.
2026-08-04 08:22:57 -04:00
MartinNYHC 932a229dea Merge branch 'dev' into feature/slicer-multi-button 2026-08-04 14:09:58 +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
Pascal eda2e04599 Added option to open/slice files in local slicers when slicer api is enabled 2026-08-03 23:41:22 +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 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 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
MartinNYHC 8b46006644 Merge branch 'dev' into feature/oidc-env-config 2026-08-01 11:22:58 +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
MartinNYHC 21d61c3535 Merge branch 'dev' into feature/oidc-env-config 2026-07-31 17:05:24 +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