Commit Graph
794 Commits
Author SHA1 Message Date
MagicMelody84 54af3146a3 [Feature]: Bind Home Assistant sensors to storage locations (dryboxes/bins) (#2827) 2026-08-25 12:17:23 +02:00
maziggy 5dd7bd213f Read a print's destination from the report topic, not just the request one (issue #1820)
current_project_url was assigned in exactly one place, _handle_request_message,
and _on_message calls that only for the request topic. A print started from the
printer's own screen publishes nothing there, so the field stayed None for the
one case the storage verdict exists for: the file is already in the printer's
model library under /userdata/model/history/, which port 990 does not serve.

The verdict then fell through to the sdcard flag, and @ojimpo's H2S reports that
flag true -- its "card" is the internal eMMC -- so every such print ran the full
sweep before giving up. He measured one: 16 filename-and-directory attempts over
22 FTPS connections, 18 of them refused, 6.4 seconds, then a fallback archive
holding a name and nothing else.

The printer does announce where the file lives, as an unsolicited project_file
*response* on the report topic about two seconds before gcode_state reaches
PREPARE. _process_message now reads the url off it, gated on result SUCCESS and
a non-empty value so a refused dispatch cannot name a file that was never
written. Reading it there rather than only at the request topic also covers an
install neither of us had in view: some brokers refuse the request-topic
subscription, and on those no print of any kind had ever populated the field.

The new branch captures state and nothing else. The "External project_file
payload" diagnostic stays with the request-topic handler: our own dispatch is
echoed on both topics, the request-topic echo lands first and clears
_own_project_file_key, so reusing the diagnostic here would have logged every
Bambuddy-started print as somebody else's. A test pins that.

What the print names is now what gets tried -- the five directories a copy could
be in, rather than the ~110 connections that cannot succeed. The probe is still
worth running: an H2S keeps recently used jobs under /cache and archives them in
full while they last, which is why the reporter's two prints on the same day
behaved differently. Slicer-sent prints are unchanged.

The banner no longer describes a step that never happened. With no reason
recorded, a blank archive fell back to the original wording -- "Store sent files
on external storage" is off in your slicer -- which on that printer is on, and
which the internal-storage wording from #2780 already explains would not help on
an H2. The archive that most needed that explanation was the only one that could
not be given it.

So file:///userdata/ now earns its own reason, internal_history, separate from
the brtc://emmc dispatch case. A dispatch chose internal storage and can be
aimed elsewhere; a print of a file that was already there had no dispatch at
all, and telling that operator to pick External in Send names a dialog they
never opened. The banner and the connection diagnostic both read the verdict's
reason rather than a fixed one, so the two surfaces cannot give the same printer
different advice. Thirteen locales, and a wiki section the banner links to.

-----

Read the K-profile selection when the mutation runs, not when it is captured

Configure Slot sends cali_idx from selectedKProfile, and the mutation read it
through its own closure. React Query hands a mutation its options from an
effect, so a click landing between a commit and that effect flushing runs the
previous render's mutationFn -- one that captured the selection as it was before
the K-profile query resolved. The payload then carries cali_idx -1 and the
printer binds the default 0.020 instead of the calibrated K, while the dialog
shows the right profile selected throughout.

It surfaced as an intermittent failure of the per-nozzle K-profile test, about
one full-suite run in six. Reproducing it with staggered query resolution showed
the divergence directly: the select element held the correct profile immediately
before and after the click, and the payload still carried -1. That test's slot is
the most exposed case in the file -- a right-hotend slot carrying the left
hotend's index, where the "keep showing the active profile" safety net cannot
repair an empty recompute.

The selection now goes through a ref written during render, so the mutation
resolves it at execute time. An effect would have inherited the same flush
ordering this exists to escape. The K value and the profile's ids travel in the
same payload and had the same exposure, so they move with it.

Measured over a staggered-resolution grid: 2 failures in 15 runs before, 0 in 12
after. api.getSlicerPrinterModels was also missing from the test file's mock, so
that query ran with no query function and rejected in all 37 tests -- mocked now,
though on its own it changed nothing, which is how the ref was confirmed as the
fix rather than assumed.
2026-08-25 11:19:46 +02:00
maziggy 06e5114a03 Do not report a printer as Safe when nothing is checking it (issue #2952)
The printer card's AI badge collapsed every class that was not Warning or
Failure into green Safe, and the service reported `safe` whenever it had no
verdict. The state entry is created when a monitored print is first seen --
before the first snapshot, let alone the first inference -- so a rejected ML
API token, an unreachable ML API, a failed capture and an unset External URL
all rendered as a healthy watched print: green Safe at score 0.000.

For a safety feature that is the worst failure mode available: it asserts the
print is being watched exactly when it is not. The reporter read that badge and
concluded the loop had never started. It had been calling the ML API every ten
seconds and being turned away with a 401 -- invisible because Obico's auth
layer rejects a bad token before its request log sees it, and because
successful checks log nothing there either.

Add two honest states. Not checking (amber) when the last poll produced no
result, carrying the reason; Starting while a monitored print waits for its
first result. Score and frame count are withheld while not checking, since
0.000 beside "Not checking" reads as a measurement rather than its absence.
The reason is per printer, so a card names its own problem rather than
whichever printer failed most recently, and stays behind settings:read because
it can quote configured URLs -- the badge state does not, because whether a
print is watched is not configuration. An unrecognised class now falls back to
Starting, not Safe.

Test Connection saves the form before probing, so a green result describes the
configuration the loop actually runs with rather than what is typed in the
boxes.
2026-08-25 08:49:24 +02:00
maziggy b1f5ec9642 Let one checkbox say where a slice's settings come from (issue #2942)
Two features in the slice dialog read as one. "Use the file's built-in
settings" slices a 3MF the way its designer set it up, ignoring the picked
profiles. The per-option "from file" ticks beside each setting carry the
designer's individual deviations onto the profile you picked, and those
arrived pre-ticked whatever the checkbox said. So a slice run deliberately
without the file's settings still took sixteen values out of it -- the
reporter's log names them, enable_support and support_type among them,
landing on a process preset they had chosen on purpose.

The ticks now follow the checkbox. Off, nothing comes out of the file until
it is asked for by name; on, every setting the file changed shows ticked,
because on that path the file really does drive the whole slice. Taking the
designer's work in bulk is still one click, from a line at the top of the
panel that says how many settings the file changed -- it is the only way
left to reach them without hunting for chips across six pages of 348
options -- and it still leaves the machine-tuned keys and the two that are
the picked preset for a per-key decision.

The panel greys out options the slicer's own rules switch off, and it was
evaluating those rules against what the user had typed alone, falling back
to the compiled-in schema defaults for the rest. A preset with supports on
therefore read as enable_support: false and greyed out the whole Support
page while the slice ran supports. A greyed row greyed its tick too, which
is how the reporter's screenshot shows a support type marked "from file",
applied to the slice, and impossible to clear. The rules now see what the
slice will actually run with: the preset's values, the file's values for
the keys that are on, and anything typed on top. The tick is no longer
gated on those rules at all -- it answers a different question, not whether
an option is in play but where its value comes from.

Underneath both, the support carry-over ran outside the ticks entirely,
lifting four keys out of any 3MF that had supports on with nothing on
screen able to decline. It now stands down for the keys that were offered
and turned down, which the request can say for the first time: an empty
design_overrides list means the caller was shown the file's settings and
took none, where no list at all is a caller that predates the choice. That
distinction is what keeps the carry whole for sources with no deviations to
tick, an OrcaSlicer export among them, rather than trading one silent
default for another.

Worth knowing: a Bambu Studio file with supports enabled no longer switches
supports on for you. Tick Enable support, or the checkbox above the panel.
Measured against the reporter's own sixteen keys, and covered by backend
and frontend tests -- reverting any one of the three changes fails tests.
2026-08-24 11:04:33 +02:00
maziggy cbbdab86f9 Say which blue a colour mismatch is about (issue #2941)
A print was refused a colour match between two filaments the dialog itself
labelled "Blue". The comparison was right: the slicer profile asked for a
near-pure #0028FF and the slot held Bambu's navy #0A2989, 118 apart in the
blue channel alone and a CIEDE2000 distance of 15, where 1 is a
just-noticeable difference. Nothing on screen said so. A hex that misses the
colour catalogue is named by a coarse family bucket, so both sides resolved
to the same word, and the warning sat between two identical labels with
nothing to reconcile it against. Read as a broken matcher, which is what the
report said, and a fair reading of what was shown.

Where both sides of a mismatch carry the same name they are now qualified by
their hex, and the tooltip names them together: "Same type, different color:
needs Blue (#0028FF), slot has Blue (#0A2989)". Names that already differ are
left alone -- the hex is noise once the words separate them -- and a side with
no name falls back to its hex rather than growing an empty bracket.

The comparison is untouched. It was correct, and its tolerance is not
something to widen on one report: a difference that size would start matching
navy to cyan, and eligibility is the same rule the queue scheduler dispatches
on, so loosening it would change which spool gets printed rather than only
which warning gets shown. This issue was about being able to see why the
warning fired, and a test pins that it still fires.

The strings around it were hardcoded English: the panel's status line, the
required-filament tooltip, the auto-matched marker, the slot placeholder, the
mismatch detail and the type-not-found message. All nine now go through
translation, in all thirteen locales. Two of them -- "Same type, different
color" and "Filament type not loaded" -- turned out to have had translations
sitting in every locale file the whole time, unused, while the component
rendered the English literal beside them. The parity gate could not have
caught any of this: keys that were never added have nothing to compare
against.
2026-08-24 10:14:31 +02:00
maziggy 87e0a4c3b6 Name a spool by its subtype on the slot it is assigned to
A spool's subtype is half of what it is called: "PLA" and "PLA Wood" are
    different filaments. The AMS slot's hover card built the assigned-spool
    line out of brand, material and colour name and left the subtype out, so
    a roll of Bambu PLA Wood Classic Birch in an H2C's A4 was announced as
    "Bambu Lab PLA - Classic Birch".

    Everything else named it correctly at the same moment -- the RFID read,
    the inventory row, the slot's own profile line, which is built from the
    spool's slicer preset rather than reassembled, and Bambu Studio -- so the
    one wrong line read like a bad tag read rather than a display fault.

    It was not only the render. The card's assignedSpool prop had no subtype
    field at all, and the six places the printer card fills it in -- regular
    AMS, AMS-HT and external spool, each in both Spoolman and internal-
    inventory mode -- never passed one, so the value could not reach the
    component. The field is required rather than optional, which is what
    stops the next call site from quietly omitting it; that omission is the
    whole of this bug.

    Three more surfaces rebuilt the name the same way and are fixed with it:
    the SpoolBuddy AMS slot panel in both inventory modes, and the write-tag
    confirmation. Every other place a spool is named -- the assign dialogs,
    the inventory cards, the forecast rows, the label picker -- already
    included the subtype, so these four were the outliers.

    This is the display-side half of #2902, which stopped the backend
    reducing a filled or foamed filament onto its base material. The card was
    doing the same thing to the same spools, one layer further out.
2026-08-23 13:52:32 +02:00
Sean K 55cc64c87d Add printer video downloads and range selection (#2853) 2026-08-22 13:22:56 +02:00
maziggy 4e40a5022c Register a Spoolman extra field with its own write (issue #2903)
Spoolman rejects a spool whose extra dict carries a key it has not been
told about, answering 400 "Unknown extra field tag.". Bambuddy keeps the
tray UUID in extra.tag, so that key has to exist before the first spool
is created. Registration ran from three hand-maintained lists that fire
when the integration is set up -- the connect route, startup, and two
inline blocks in the inventory routes. Enabling Spoolman from the
Settings page reaches none of them, so the first AMS sync on a fresh
Spoolman failed on every slot while vendor and filament creation
succeeded.

Neither fix suggested on the issue is quite the right shape. Adding the
block to PUT /settings/spoolman fixes this path and makes a third copy
of a list that has already drifted -- it would still omit
bambu_color_name. Ensuring at the sync entry point leaves the other four
tag writers alone: linking and unlinking a tag, and both inventory edit
paths.

So the registration moved to the write. create_spool, update_spool and
update_spool_full each register the keys of the extra dict they are
about to send, once per client, before sending. Every tag writer funnels
through one of the three, merge_spool_extra included. This closes the
class rather than the instance: a write that carries a key is a write
that registers it, and bambu_color_name shows why that matters -- it
never made it into the connect or startup lists at all, and works today
only because two call sites remembered it by hand.

Best-effort, deliberately. ensure_extra_field already logs and returns
False rather than raising, so a registration that fails leaves the write
to be attempted and to report exactly what it reported before. Failures
are not memoised either, so a Spoolman that was merely restarting gets
another try on the next write.

The older blocks stay. They are redundant now, but the inventory routes'
inline calls are pinned by tests that assert them against a mocked
client, where the funnel cannot run.

The status endpoint is the other half. The Connect button would have
registered the fields, and the reason nobody reaches it is that
GET /spoolman/status reported "connected" whenever an earlier request
had left a client object behind. Roughly twenty call sites build one
lazily, and saving the Settings page builds one as a side effect of
syncing locations, so the flag turned on which page had been opened
rather than on anything about Spoolman. The UI reads it twice -- Connect
only while disconnected, the sync section only while connected -- so
those two controls landed in states the user cannot explain. It now asks
the Spoolman that is configured, including the stale-URL check every
other route already does, and does not probe at all when the integration
is switched off, which used to let a leftover client report a disabled
Spoolman as connected.

That leaves nothing for Disconnect to do, so it is gone. Spoolman is a
stateless HTTP API with no session to close; the button dropped the
client object, the next request rebuilt it lazily, and the status
flipped back on its own within the 30s poll -- an action that looked
like it worked and then quietly undid itself. The enable toggle owns
turning the integration off. Connect stays as what it always was in
practice, a way to re-check a Spoolman that is not answering, and is
shown only then. Its translation keys are left in place; only the
control is removed.

Resolving the client there means the status poll can now fail in ways a
read-only check could not, so it no longer reports failure by failing.
Replacing a client closes the previous one and httpx's aclose() is not
guaranteed not to raise; a poll that runs every 30 seconds answering 500
is worse than one answering what is true either way, which is that
Spoolman could not be reached. The SSRF rejection keeps its own message
rather than folding into the general one -- a URL the guard refuses is
the admin's to correct, and that is only actionable if the log says so.

Tests drive the real client against a fake Spoolman that enforces the
unknown-extra-field rule rather than mocking it away, so each one fails
against the old code for the reason the reporter's install did. The
concurrency test needed the fake to be async: MockTransport answers
without suspending, so the first version ran each request to completion
in turn and passed just as happily with the lock removed.
2026-08-22 12:26:18 +02:00
maziggy e4aab7b44b Leave archived projects out of the pickers that file work (issue #2888)
The reporter opens a project per job and archives it when the job is
done, so the Project dropdown in Edit Archive listed five live projects
behind thirty-odd finished ones, in one unscrolled run with nothing to
tell them apart.

Archived is the state that means "put this away", so that is what the
pickers now drop: the Edit Archive dropdown, the pending-uploads panel,
the bulk Add to Project dialog and the File Manager's folder link.
Completed stays. It says the work is finished, not that it should be
hidden, and filing a reprint under a finished project is ordinary.

The Archives right-click submenu had the opposite bug and offered
active projects only, so a completed project was reachable from the
edit dialog and not from the menu next to it. All five surfaces share
one rule now.

Whatever a thing is already filed under survives the filter whatever
its status. A select holding a value that matches none of its options
is reset by the browser to the first one, and here that reads "No
project" -- an archive sitting in an archived project would have said
in as many words that it was filed nowhere. The stored id does survive
an untouched save; it is the field that lies.

The parent-project picker is deliberately untouched. A finished or
archived project is still a legal parent, and its own comment says so.

Fixed alongside it, and reported separately: the status tabs counted
only the projects the selected filter had already let through, so every
tab but the current one counted zero and dropped its badge. Switching
tabs moved the number rather than showing four of them. They are
counted from the unfiltered list the page already fetches for the
sub-project captions -- same query key, no extra request.

The new rule is one function with its own unit tests, since five
callers now depend on it reading the same way. Each half is pinned
separately: dropping the filter, dropping the kept id, restoring
active-only, and counting from the filtered list each fail their own
tests and nothing else.
2026-08-22 09:27:26 +02:00
maziggy 3916db822c Stop the G-code preview from sizing the box that sizes it (issue #2887)
Opening a 3D Preview from Archives left an empty white pane with the
legend and the layer slider drawn over it, and the page's scrollbar
shrank for as long as it stayed open -- about 190px of page height a
second, with no limit.

The viewer appends its canvas into the very element it measures with
clientWidth/clientHeight and watches with a ResizeObserver. setSize
writes each new size onto the canvas as inline style, and three.js
leaves the canvas display:inline, so the line box adds descender space
on top of the height just set. Where that element takes its height from
its contents, the canvas sizes the box that sizes the canvas and gains a
fixed 33px every round -- the reporter measured container = canvas + 33
on every sample.

The page gave it no height to take instead. The viewer pane is flex-1
min-h-0, which divides nothing unless the column above it is a definite
height, and h-full is a percentage resolved against a main area whose
own height comes from a min-height -- a floor, not a size. So it fell
through to the content, and the content was the canvas.

Nothing was ever drawn because of the same loop, not a second fault:
every observer callback reallocated and cleared the frame buffer, and an
antialiased render of what had grown to roughly 18 megapixels never
finished before the next one arrived. The data path was fine throughout,
which the legend and the 1..57 layer slider both prove -- they are built
from the parsed toolpath.

The canvas is now positioned out of flow, so it cannot contribute to the
height of the element that measures it on any page, and that element
takes a definite height from the pane around it rather than a
percentage. display:block goes on too, for the case where something
overrides the positioning. The page is sized from the viewport the way
the File Manager page already was.

Either change alone stops the growth, but the structural one alone would
trade it for a collapsed pane: an out-of-flow canvas contributes nothing
to content height, so with no definite height above it the pane becomes
clientHeight || 1. They belong together.

The same viewer in the File Manager dialog was never affected -- a
dialog gives it a fixed height, so neither fault could arise there.

jsdom does no layout, so the loop cannot be reproduced in a test. The
structure that forbids it can: the new cases assert the canvas is out of
flow, that the measured element is definite-height rather than h-full,
and that the pane stays positioned so inset-0 resolves against it.
Reverting either change fails exactly those.
2026-08-22 09:05:12 +02:00
maziggy 3901043238 Name an AMS slot's colour by its material, not its hex alone (issue #2875)
A hex is not one colour in Bambu's range. #FFFFFF is Jade White in PLA
Basic, Ivory White in PLA Matte and plain White in six other materials;
popover resolved its title from the hex alone, against a map that keeps
one name per hex, so an ivory Matte spool read "Jade White" while the
profile line beside it correctly read Matte Ivory.

/inventory/colors/map now carries the names collapsing loses, keyed
"<material>|<hex>". An entry is emitted only when it recovers a name the
same manufacturer's own range lost -- 11 of them against the 608 colours
in the shipped catalog. Both halves matter: a name equal to the flat
answer is weight, and a name from another brand is not a recovery, it
would put Prusament's "Pristine White" on every generic white PLA slot.

A slot with a spool assigned from Inventory is titled with that spool's
own colour name: it is the roll the user said is in there. Bambu
internal codes are still rejected as non-names (#857).
2026-08-19 09:06:11 +02:00
maziggy 7e77bf5833 Take the layer height from the plate that actually printed
The archive card, the library file details and the slice dialog all read
the layer height from a 3MF's project_settings.config. That records the
project's settings and can still describe an earlier process or another
plate; the plate's own G-code - what the printer executes - was never
consulted for it, because the parser read the first 4KB, enough for the
layer count in the header block but not for the config block that carries
layer_height 14-25KB in. A print running at 0.08 on the H2C archived as
0.2 with the layer count from the same file correct beside it.

The plate G-code now wins wherever the two disagree, and the plate that
was printed is the one read - the header parse used to take the first
gcode entry in the zip regardless of which plate the archive was for.
Source 3MFs, which carry no G-code, keep the project value as before.

---

Stop carrying a file's layer height over the preset you picked

Bambuddy carries a designer's process deviations across a re-slice
(#2622) and pre-ticked every one that was not machine-coupled.
layer_height is one MakerWorld projects routinely carry, so picking
"0.08mm High Quality" for a file whose designer had moved layer height to
0.2 sliced at 0.2 while the dropdown still read 0.08 - the same 0.2 the
settings panel showed, tagged "from file".

Layer height and first layer height are now classified preset_defining
and treated like the machine-coupled keys: offered, never pre-selected.
The flag travels on DesignOverride so the modal and the backend agree,
and the panel's badge names the conflict and shows the preset's own value
next to the file's, so ticking one is a deliberate choice.
2026-08-18 16:23:32 +02:00
maziggy d227d42272 Let a print with no 3MF be given its filament weight (issue #1820)
When the sliced file stays somewhere Bambuddy cannot read, the archive is
built from the printer's report alone and carries no weight. Nothing could
supply one afterwards: rescan reads the figure out of the 3MF, and that
archive has no file to read. The reporter's H2S print left 46.16 g on the
spool with nothing recording it, and he corrected Spoolman by hand.

Edit Archive now has a Filament used (g) field. It is written to the
archive's most recent run as well, because the Projects roll-up and the
Prometheus counter sum PrintLogEntry rather than the cards - correcting
only the archive would fix the display and leave every aggregate reading
the old figure, or none at all.

But not over a figure the run measured for itself. A run's grams come from
the tracked spool delta when there is one and only fall back to copying
the archive's estimate when there is not, so mirroring unconditionally
would overwrite a measurement with a typed estimate. The mirror now takes
a run that has no figure, or one holding exactly what this archive held -
which also makes the undo complete, since clearing the archive clears the
copy it made and leaves a measured run alone.

The field is text rather than a number input. A number input reports an
empty string for anything the browser judges malformed, a decimal comma in
a locale that does not expect one included, and that reads here as "the
user cleared it" - it would have wiped a good figure while the field still
showed what was typed. Filtering on the way in keeps what is displayed and
what would be sent the same string, and clamps it to the range the API
accepts: this modal has no error surface, so a refused save looks like
nothing happened at all.

Saving also invalidates the archive's runs query. The Print Log this modal
renders at its top reads them separately and kept serving the pre-edit row,
so a correction looked like it had not taken - true for the status and
failure-reason mirrors since #1444 as well.

Second half of the same report: the internal-storage probe (#2856) logged
which file it found but not where. On a printer that keeps uploads for
weeks - the reporter has months of them in /cache - a reprint of a name
that was re-sliced but never re-sent can match an older copy, and without
the directory that mismatch is invisible rather than merely rare. The
download helper returns the path that served the file instead of a bare
flag; every caller only ever tested it for truth.
2026-08-18 10:36:42 +02:00
maziggy 90fac7b529 Keep card and row actions reachable without a hover-capable pointer (issue #2865)
Tailwind v4 compiles group-hover: inside @media (hover: hover) - the
shipped CSS has .group-hover\:opacity-100 sitting in exactly that block.
On a touch-only device the media query never matches, so the rule that
reveals the control is not merely never triggered: it is never applied.
A control written as opacity-0 group-hover:opacity-100 is invisible for
good. The reporter's iPhone screenshot shows the project card with
nothing where the "..." belongs, and Edit and Delete live only there.

So the hiding half is what has to depend on the pointer, not the
revealing half. A can-hover variant carries the query - the same one the
history thumbnail preview has used since it was written - and the six
controls behind it become can-hover:opacity-0 group-hover:opacity-100.
Without a hover-capable pointer no rule hides them and they simply
render; with one, nothing changes. Every reveal selector is specificity
(0,2,0) against the hider's (0,1,0), so which one wins does not depend on
where they land in the stylesheet.

Six controls were affected: the project card menu, the File Manager's
folder actions (reachable by accident today - the "wrap names" toggle
drops the hover class), duplicate preset, rename and delete tag, delete
print photo, delete plate reference.

Six more already tried to handle this, by viewport width under 768px on
the Archives cards and the File Manager's file cards. That covers a phone
and misses an iPad in landscape, which is touch-only at 1024px. They move
to the capability check and useIsMobile goes with them, along with the
isMobile prop threaded into FileCard.

opacity-0 also leaves a button focusable while invisible, so tabbing
through a card stopped on a control nobody could see. Focus now reveals
them, through group-focus-within on the wrappers and focus-visible on the
standalone buttons - neither is hover-gated.

jsdom does not evaluate media queries, so the tests pin the class
contract instead: a bare opacity-0 is the defect, because it applies
unconditionally while everything that undoes it does not.

The decorative hover reveals are left alone - the archive hash badge, the
project name overlay, the thumbnail preview, the swatch tooltips. Nothing
is unreachable there, only unseen.
2026-08-18 09:53:46 +02:00
maziggy c5e0055864 Track which nozzle each AMS feeds through the Filament Track Switch
With a switch fitted, an AMS is not wired to a nozzle any more. It is
plumbed into one of the switch's two inlets and reaches both nozzles
through it, so every unit reports its extruder as "not fixed" (0xE) and
ams_extruder_map comes back empty. Bambuddy had nothing to fall back on
but the AMS unit number, so AMS-A was badged R and AMS-B was badged L
purely because their unit ids are 0 and 1, a third unit got no badge at
all, and every one of those labels was wrong.

The binding needed no new telemetry. BambuStudio reads it out of bits
24-27 of the same AMS info string we already parse for the type and the
extruder id -- 0 is In-B, 1 is In-A -- and it only means anything when a
switch is installed, because without one 0xE really does mean an
uninitialised unit. That gates the read, which forced the switch block to
be parsed before the AMS block: _handle_ams_data runs early in
_process_message and _update_state only much later, so the binding was
lost on every frame that carried both.

The badge letters stay L and R, In-A reading as L, with the inlet named
in full in the tooltip -- the letter is the inlet's position and not a
claim about which nozzle that AMS feeds, since the switch can route
either inlet to either outlet. Both views update live now. The switch
fields were missing from the WebSocket payload, and the frontend
shallow-merges each push over its cached status, so an absent field kept
whatever the last full fetch left behind. The broadcast dedup key had no
term for them either, so "Join IN-B" on the printer screen moved nothing:
the binding is not in the tray component of that key, and not in the AMS
change-hash, which covers tray fields only and must stay that way because
it drives Spoolman sync.

The rest of this is the calibration half, which is where it actually
bites. K-profiles are calibrated per nozzle and the printer numbers its
calibration table per nozzle too, so entry 16 exists on both hotends and
means a different profile on each. A tray holds exactly one index. Move
an AMS to the other inlet and every configured slot in it silently keeps
pointing at the old hotend's table -- measured on the maintainer's H2C, a
black PLA calibrated 0.018 left and 0.020 right stayed on the left
profile after the move, and a manual RFID re-read only re-asserted the
same wrong one. Bambu has not solved this either; their AMS dialog
carries "TODO: fila_switcher broken the connection of ams->extruder"
above the line that decides which nozzle's profiles to offer.

Three copies of "which extruder is this slot on" each ended in else 0,
which on a switch machine filed every profile under the right-hand
nozzle. They now share one resolver that returns None for "unknown",
because unknown and extruder 0 are very different answers on a
dual-nozzle machine and conflating them is what bound a left-nozzle
profile to a slot sitting on the right. The per-slot K value on the
printer card had the same confusion from the other direction: its lookup
was keyed on cali_idx alone, so one nozzle's K silently overwrote the
other's.

Moving an AMS now re-selects each configured slot's counterpart profile
for the nozzle it has arrived on. Only the calibration binding moves, and
only for slots whose spool already has a profile for that nozzle:
configuring a slot is a deliberate preparation step, so a slot we know
nothing about, or a spool calibrated on one hotend only, is left exactly
as the operator set it. Nothing fires on the first sighting of a binding
either, since every reconnect learns them afresh and re-applying there
would overwrite a choice made by hand.

Configure Slot resolves against the slot's own nozzle throughout. Option
identity carries the extruder, so a filament calibrated on both hotends
gives two distinguishable entries instead of two that collapse into
whichever the printer listed first; options name the hotend; matches are
scoped to the nozzle the slot feeds, with the other hotend's profiles
still reachable under Other K profiles; and the slot's active index is
resolved as a pair rather than followed into the wrong table.

Inlet to nozzle is one table, In-A to the left hotend and In-B to the
right, measured rather than assumed -- fila_switch.out cannot be used for
it, reporting [1, 1] unchanged across a 90-second capture, both outlets
claiming the same extruder. The print dialog picks up the same inlet
labelling in its slot dropdown, replacing a left/right hint that never
rendered because it matched snow-encoded values against global tray ids;
decoding it correctly would not have saved it, since the firmware never
reports which inlet is currently paired with which outlet. The dialog
also notes when every filament a print needs sits behind one inlet, which
is legal but slow -- a change between two filaments on the same inlet
retracts the outgoing spool all the way back to its AMS, where a change
across the two only retracts as far as the switch.

Assigning an AMS to an inlet remains printer-side. BambuStudio can read
that binding and has no command to write it, so there is no wire format
to copy.
2026-08-16 15:59:59 +02:00
maziggy 7a42e0a7e5 Show which Filament Track Switch inlet each AMS feeds
With a switch fitted, an AMS is not wired to a nozzle any more. It is
plumbed into one of the switch's two inlets and reaches both nozzles
through it, so every unit reports its extruder as "not fixed" (0xE) and
ams_extruder_map comes back empty on these machines.

The printer card had nothing to fall back on but the AMS unit number, so
AMS-A was badged R and AMS-B was badged L purely because their unit ids
are 0 and 1, a third unit got no badge at all, and every one of those
labels was wrong. The SpoolBuddy assign modal had the same fallback in a
worse form, mapping anything that was not extruder 1 to R.

The binding turned out to need no new telemetry. BambuStudio reads it out
of bits 24-27 of the same AMS info string we already parse for the AMS
type and the extruder id -- 0 is In-B, 1 is In-A -- and it is only
meaningful when a switch is installed, because without one 0xE really
does mean an uninitialised unit and those bits carry nothing. That gates
the read, which in turn forced the switch block to be parsed before the
AMS block: _handle_ams_data runs early in _process_message and
_update_state only much later, so the binding was lost on every frame
that carried both. _parse_fila_switch is split out and called first, and
left in _update_state as well so that stays a complete absorb step.

The badge keeps L and R rather than A and B, because the lettering is
familiar and matches the physical layout. It is a different colour from
the plain nozzle badge, and its tooltip names the inlet in full, since
the letter is the inlet's position and not a claim about which nozzle
that AMS feeds -- the switch can route either inlet to either outlet. An
AMS still reporting a real extruder id keeps its ordinary badge, which
BambuStudio also treats as authoritative over any switch binding, and a
switch that has been fitted but not yet set up on the printer shows
nothing rather than a guess.

The print dialog's slot dropdown gets the same label. It replaces a
left/right hint that never once rendered: ftsExtruderForSlot compared
snow-encoded in[] values against global tray ids and could not match.
Decoding it correctly would not have saved it -- the firmware reports
which slot sits in each inlet and which nozzle each outlet feeds, but
never which inlet is currently paired with which outlet, so no per-slot
nozzle can be derived. That function is gone rather than fixed.

The dialog also points out when every filament a print needs sits behind
one inlet. Bambu's own guidance is that this is legal but slow: a change
between two filaments on the same inlet retracts the outgoing spool all
the way back to its AMS before the next can be fed up the shared tube,
where a change across the two inlets only retracts as far as the switch.
All on one inlet means every change in the job takes the slow path, and
moving a single spool fixes it. So it advises, it does not block.

Both views update live. Two things were stopping that. fila_switch and
ams_switch_inlet were absent from printer_state_to_dict, and the frontend
shallow-merges each WebSocket push over its cached status, so a field the
push omits keeps whatever the last full fetch left behind. And the
broadcast dedup key had no term for either, so "Join IN-B" on the printer
screen moved nothing: the binding is not in the tray component of that
key, and it is not in the AMS change-hash either, which covers tray
fields only and must stay that way because it drives Spoolman sync.

Assigning an AMS to an inlet remains printer-side. BambuStudio can read
the binding and has no command to write it -- its switch class is parse
and getters only, and the recommended-arrangement popup draws and
publishes nothing -- so there is no wire format for us to copy.

Adding the two fields to PrinterState broke four test modules whose
SimpleNamespace stubs predate them. The stubs are fixed rather than the
production reads made defensive: the real dataclass always carries both,
and a getattr in the dedup key would silently stop tracking the field if
it were ever renamed.
2026-08-16 15:09:00 +02:00
maziggy 47a37618a0 Let a busy or offline printer take a dropped file (#2849)
Dragging a sliced file onto a printer card refused the drop unless the
printer was connected and neither RUNNING nor PAUSE. The overlay went red
with "Printer busy", handleCardDrop returned early, and the file was
discarded with no toast and nothing uploaded. The card's Print button was
hidden by the same condition, so both routes into "Print from Printer
Card" closed at once and the way through was the File Manager, uploading
and queueing by hand.

The gate never described a real constraint. Every print Bambuddy sends
becomes a queue item; dropping onto an idle printer only looks instant
because the scheduler dispatches it on the next pass. Busy is a timing
difference, not a different path. The modal has always passed
disableBusy={false} to PrinterSelector, and asapToastShouldPromiseLaterStart
exists precisely to say "this will start later" when the target cannot
take it now. cleanup_library_after_dispatch is a print_queue column
consumed at dispatch, not on close, so the transient upload survives
however long the item waits.

Offline is included for the same reason: the queue dispatches when the
printer comes back, so a machine that is powered down can be given work.

The overlay now says which one is happening -- "Drop to print" when the
job would start immediately, "Drop to queue" when it would wait, covering
a print in progress, a paused job, an AMS mid-cycle, a plate not yet
cleared, and a printer that is offline. The predicate behind that wording
is the one the modal already used for its own later-start notice, lifted
out of PrintModal into utils/printer as isPrinterCurrentlyDispatchable so
the card cannot promise something the modal contradicts a second later.

The drop is also gated on the permissions the flow actually exercises. It
uploads to the library and creates a queue item, so library:upload and
queue:create -- the pair the Print button beside it has always checked.
printers:control, which it checked before and never uses, meant someone
holding that alone got the file uploaded and then rejected by the queue,
leaving a library row behind with nothing pointing at it. The refusal now
names whichever of the two is missing instead of claiming the printer is
busy.

printers.cannotPrint is dropped in favour of printers.dropToQueue across
all 13 locales; its text was both unused and, after this, wrong.

The Print button stays inside the expanded-card block, so S-size cards
still show the drop zone and no button, exactly as before.
2026-08-16 12:59:59 +02:00
maziggy 713a85d114 Let a bug-report capture outlive the panel that started it (#2847)
Step 2 asks the user to reproduce the problem, and the panel sits over
the part of the app they have to reach to do it. Closing it was the
obvious move and it was wrong in two different ways, picked by timing
alone. Reopen inside five minutes and the reset-on-open effect put you
on an empty step 1 while the server stayed at DEBUG, with nothing left
in the flow that could stop it -- only Stop & Submit ever did. Leave it
closed and the cap fired behind you: the panel is hidden but mounted, so
the timer kept running, stopped logging and filed the report with no
window open and no confirmation.

A capture is now written down -- description, email, was_debug and a
start timestamp -- and the reset skips a run in progress, so the panel
reopens on the step it left. The disc turns amber while a capture is
going and Layout marks the compact header's button and offers Resume
report on the debug-logging banner, since a run started there ends at
that panel's button and not at the System page's raw toggle. If the cap
fires while the panel is closed, it opens first, so the submission
happens in front of the user.

Elapsed is measured against the start time rather than counted in ticks,
which a background tab throttles hard enough that five minutes was not
five minutes. That timestamp also lets a run survive a reload, which
matters because reloading is an ordinary step in reproducing a bug: on
mount a stored run is reconciled against /support/debug-logging and
resumed. One that outlived the cap unattended is not resumed and not
filed -- an hour-old description is not a report anyone still expects --
but its log level is put back, which is what stayed wrong indefinitely
before.

The screenshot is deliberately not persisted: a 1920px JPEG runs to
hundreds of kilobytes against an origin-wide budget, and it survives a
close either way.
2026-08-16 09:55:12 +02:00
maziggy 3954d3a7e6 Choose which rack nozzle each filament prints from on an H2C (#1784)
The Vortek rack holds six hotends, and a multi-colour plate is sliced to
use a different one per colour so it can skip the purge. Which of the six
each colour takes is not in the 3MF. The same plate, sliced and sent twice
from Bambu Studio with a different choice each time, produces two files
that differ only in rounding in the last digit of a few extrusion figures
-- the filament grouping, the toolchange stream, the 120 nozzle-change
markers and project_settings.config are all identical. The choice travels
only in the dispatched nozzle_mapping.

Bambuddy had no way to state it, so those plates went out with no nozzle
assignment at all and the printer chose for itself. That is what levelled
on one hotend and printed with another, millimetres above the plate.

Every rack-bound filament now carries a position picker beside its AMS
slot dropdown, listing all six with the nozzle each holds. An empty
position, or one holding the wrong diameter or flow type, is shown greyed
out with the reason rather than hidden, so someone looking for position 4
finds it. The choice is per filament *group* rather than per slot, because
a group is one hotend: two filaments the slicer grouped together share it
and cannot point at different positions.

Nothing has to be picked. Positions are assigned automatically, preferring
one already loaded with that colour, which on the plate this was built
against reproduces Bambu Studio's own pick exactly.

A nozzle currently picked up onto the carriage is offered too. The
firmware drops its rack position from the report entirely rather than
sending a placeholder (#943), and refusing it would rule out the position
most likely to be wanted -- the one the last print left mounted. Only
recoverable when exactly one position is missing; two gaps are genuinely
ambiguous and stay unavailable.

Positions are re-checked at dispatch, not just when queued, because the
rack can be re-loaded in between. The two failure modes differ on purpose:
an explicitly chosen position that no longer fits stops the print, names
what the position now holds, and deletes the uploaded file from the SD
card so it cannot be started by hand either -- an operator who named a
hotend must not silently get a different one. An automatic assignment that
cannot be made instead falls back to letting the firmware choose, which is
what happened before any of this existed.

The pick is stored as {group: position} rather than as the expanded
nozzle_mapping, though that is what goes on the wire. That column means
"Bambu Studio decided, forward verbatim", and only the group-and-position
form can be re-checked against what is actually mounted at dispatch.

The existing multi-rack refusal in extract_nozzle_mapping_from_3mf stays.
It still guards the #2800 fallback, which can only ever name one rack id.

Measured on the maintainer's H2C: rack position n is physical nozzle id
15 + n, confirmed by cross-referencing two captured dispatches against
Bambu Studio's own dialog. extruder_max_nozzle_count names which carriage
is the rack straight from the file, and is read rather than assumed -- a
fourth independent confirmation of the carriage indices fixed in 45dc139.

The print dialog is also wider, on every printer. Its filament rows carry
the most horizontal content in it and adding a picker truncated names to
"Bamb...". The column widths themselves only change on a rack machine.

Tests: 44 unit covering the plan, the resolver, the mounted-nozzle
recovery and every refusal; 9 dispatch integration asserting the two real
captures end to end; 7 API round-trip; 33 frontend. The API ones exist
because two integration bugs got through a green suite that tested the
pieces and not the seams -- the group data reached only one of the three
filament-requirements routes, and the field was declared on every schema
except the create one, where Pydantic dropped it in silence.
2026-08-14 11:27:50 +02:00
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