Commit Graph
3812 Commits
Author SHA1 Message Date
maziggy e2a2e06da3 Updated CHANGELOG 2026-08-24 11:53:53 +02:00
Kouki Ojima f95c81e6cd Take an RFID spool's core weight from the row that names it (issue #2909) (#2923) 2026-08-24 11:51:22 +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 6988a30eae Carry a fault's description in the status response (issue #2926)
The HMS catalogue has been in the backend all along and the status
response never carried it, so every consumer that wanted to tell a user
why a print halted resolved the same 853 codes from its own duplicate of
the same sentences -- this repo's Python table, the frontend modal's, and
at least one third-party client whose catalogue exists purely because the
server would not say. Each ages separately, and a relay watching a
printer could only manage "your printer needs attention" while the server
already knew it was "Filament ran out. Please load new filament."

hms_errors[] entries now carry a description, defaulting to null so a
client that has never seen the field is unaffected.

It is resolved where the fault is parsed rather than at the boundary that
prompted the request, because there are three serializers of a fault, not
one: the status response, the WebSocket broadcast, and the completion
payload the queue's failure reason is built from. Adding it to only the
first would have handed half the feature to a relay watching the stream,
which is the likelier consumer of the three. The queue's failure reason
now quotes the resolved sentence instead of looking the code up a fourth
time, and the notification path reads it rather than re-deriving. That
they cannot report different text for one fault is the point, and a test
asserts they agree.

describe_fault is the single mapping from either code shape onto the
table. An 8-char print_error is the catalogue's MMMM_EEEE key with the
separator removed -- the parser derives full_code and that key from the
same 32-bit value -- so it resolves exactly. A 16-char hms[] identifier
is tried whole and then collapsed to its first and last groups.

That collapse is lossy, and keeping it was the decision worth making
carefully. #2728 counts 65 documented faults falling onto 0300_0001
alone, so a hit can attribute a neighbour's sentence to this fault, and
refusing it looks like the stricter reading. It is not: the notification
path, the queue's failure-reason helper and the frontend modal have all
resolved hms[] faults this way for as long as they have existed, and it
resolves real ones -- a 0500_4038 nozzle mismatch arrives in that shape.
Declining to collapse would have stopped describing faults that are
described today, silently suppressed the notifications they raise, and
left this field null while the UI showed text for the same fault.
Narrowing it belongs with #2728, where both key spaces can move together.

So the lookup is exactly what it was, verified rather than asserted:
a test walks every catalogue code in both fault shapes across all three
alert levels and checks the result against the derivation this replaces.
A future change to the lookup cannot quietly stop notifications firing.

The catalogue ships one language, so the field is English and
unlocalized, which the schema and the API reference both say next to it.
The camwall feed is deliberately left alone -- it is code-only because
its token travels in a URL on a screen, and a readable sentence discloses
more than the camera picture already does. The frontend keeps resolving
its own text: switching it would change what filterKnownHMSErrors counts
across eight call sites, which is #1840 and #2728's argument to have.

HMSError.message goes with this -- a text field that was never set or
read anywhere, and an invitation to populate the wrong one now that a
live description sits beside it.

-----

Record a failure code the user can actually look up

The queue's failure reason formats a fault's module and error into
MMMM_EEEE, and that one derivation never masked the error to 16 bits. A
fault arriving from the printer's hms[] array carries its alert level in
the code's high half, so the label came out as 0500_24038 -- five digits
in a group that has four. It is not a code anyone can find on Bambu's HMS
index, and because it matches no catalogue key the sentence explaining
the failure was dropped along with it, leaving the bare number alone.

The nozzle-size mismatch behind #1111 is exactly such a fault. Reported
one way it read "[0500_4038] The nozzle diameter in sliced file is not
consistent with the current nozzle setting"; reported the other, the same
physical fault read "[0500_24038]" and nothing else.

There is already a helper that gets this right, used by the archive's own
failure-reason lookup, so this calls it instead of keeping a fourth copy
of the derivation. It also takes the raw integer code the MQTT payload
carries, which the local version only handled as a string.
2026-08-24 09:47:39 +02:00
maziggy c094102614 Let a virtual printer be told which address to advertise
BambuStudio reads its FTP upload destination out of net.info[].ip in the
MQTT status, and the bridge fills that field from the VP's bind address.
On Docker bridge networking the two are different machines' worth of
address: slicers reach Bambuddy on the host's LAN IP, the container binds
something like 172.24.0.2, and that private address is what the slicer was
handed -- so it opened an FTP connection to an address that does not exist
on its network and the send stalled around 10%.

VIRTUAL_PRINTER_ADVERTISE_ADDRESS supplies the address slicers actually
use, taking precedence over the bind address and over the same-subnet host
interface the bridge falls back to. The armed log line names its source, so
(VIRTUAL_PRINTER_ADVERTISE_ADDRESS) against (bind_address) tells an
operator whether the variable reached the container at all, and the
not-armed diagnostic now names it as the remedy -- a bridge-network install
that has not set it is exactly the one that cannot auto-resolve either, and
until now saw only that nothing worked.

An environment variable rather than a change to how the advertised address
is resolved, which is the decision worth recording. The VP already has a
"Network Interface Override" field, and reading it here is the smaller
patch, but it feeds SSDP and the certificate SANs only: honouring it would
silently move the upload destination on every install that has one set --
the multi-NIC, VLAN and Tailscale setups, which are the ones most likely to
have been arrived at by hand and least likely to survive being
second-guessed. Unset, this changes nothing, and a test pins that.

A value that is not a dotted-quad IPv4 is refused with one warning naming
it and the previous address is used instead. That direction is deliberate:
declining to rewrite would put the real printer's IP back in front of the
slicer, which is the leak the rewrite exists to close, so a typo must not
be able to reopen it. 0.0.0.0 counts as unset and whitespace is stripped,
for values pasted into a compose file.

Host and macvlan networking need none of this and stay what Virtual Printer
is developed against. The variable removes one blocker; it does not make
bridge mode equivalent. The wiki said in three places that the host address
could not be discovered at all, which is no longer true, so those now
describe the variable and keep the recommendation.
2026-08-24 08:58:41 +02:00
maziggy 537b4d2509 Stop offering AMS slots as places to store a spool
The Storage Location dropdown listed entries like "H2D-1 - AMS A1" next to
    real locations, and they could not be got rid of.

    They were never locations. Bambuddy used to record which slot a spool was
    loaded into by writing that string into Spoolman's location field, and the
    writer went away when Storage Location became something the user picks --
    but the strings stayed on people's Spoolman spools, and the location sync
    imports every distinct one it finds, so they have been coming back in
    through the front door ever since. A printer slot is where a spool is
    loaded, not where it is put away, and slot assignments already track the
    first.

    Deleting one by hand did not work either, which is what made this a dead
    end rather than an annoyance: the delete route refuses a location that has
    spools, and in Spoolman mode it counts them by matching that same string,
    so every marker still sitting on a loaded spool answered 409 -- and the two
    that were empty were back on the next sync a minute later.

    The import now skips them and a one-shot migration clears the ones already
    in the catalogue. The shape is defined once and used by both: an optional
    printer-name prefix followed by AMS A1, AMS-HT A1 or External Spool, which
    is exactly what convert_ams_slot_to_location produced. It stays narrow on
    purpose -- "AMS Drybox" and "Spare AMS trays" are somebody's shelf, and
    anything the filter swallowed would be a place they could no longer file a
    spool under -- so both directions are pinned by tests.

    A row is only removed when no spool in this database points at it, by id or
    by legacy free-text name, so an internal-mode user who has deliberately
    filed spools under such a name keeps it. Spools in Spoolman are neither
    consulted nor touched: their location strings are the user's data on the
    user's server, and one that still reads "H2D-1 - AMS A1" in the inventory
    list is telling the truth about what Spoolman holds. It simply stops being
    offered as a destination.

    Verified on a live Postgres instance carrying the reported symptom: 13
    locations down to 3, all ten markers removed, the two real shelves and one
    hand-typed Spoolman name left alone.
2026-08-23 15:17:09 +02:00
maziggy b38022ec5c Draw a spool the way the AMS described it, and correct the tare it was added with
Two faults in the same auto-add path, both found while tracing why an H2C
    slot named a wood roll as plain PLA.

    A spool's swatch is composed from effect_type and extra_colors, and the
    RFID auto-add set neither. It reads the colour catalogue to name the
    colour and took the name alone, even though the row it had in hand also
    carries those two columns -- the spool form's own colour picker hands both
    to a spool a user adds by hand, so the same roll rendered one way when you
    typed it in and another when the printer identified it for you.

    Both columns now travel with the name. That alone changes nothing on a
    stock install, because the shipped catalogue carries an effect on none of
    its 600-odd rows, so the subtype is read where the catalogue has none: it
    is already derived from what the printer reports, and the two vocabularies
    line up -- Wood, Silk, Sparkle, Marble, Glow, Galaxy, Metal, Rainbow,
    Translucent, Matte, and the Gradient, Dual Color and Tri Color that the
    M*/T* colour codes upgrade a subtype to. "Silk+" reads as Silk, since the
    plus is on the product name rather than the finish. A subtype that names
    no effect -- Basic, Tough, CF -- leaves the column empty rather than
    inventing an overlay, and a value already set is never overwritten, so the
    column stays what it is documented to be: a rendering hint the user can
    override without touching Bambu's categorical label.

    ------

    Correct the spool tare an RFID roll was added with (#2909)

    The lookup that gave an auto-added spool its core_weight asked for the
    first catalogue row whose name starts "Bambu Lab" and took whatever came
    back. There are three, and which is first is the database's business:
    SQLite returns insertion order in practice, Postgres promises nothing once
    a table has seen an update. The same roll was therefore recorded with the
    216 g High Temp tare on one install and correctly with the 250 g Low Temp
    one on another. @ojimpo's forward fix picks the row by name; this repairs
    the rows already written, which the forward fix cannot reach -- 22 of 26
    RFID-added spools on the instance this was traced on.

    The tare is not cosmetic. A spool weighed on SpoolBuddy has its remaining
    filament worked out as the scale reading minus the tare, so a 34 g low
    tare credits the roll with 34 g that is not there and writes a used weight
    34 g short. That error is a constant -- every later print adds to the used
    weight on top of it -- so adding the difference back is exact however much
    has been printed since. It is applied only to spools that have been on the
    scale; one that never was has a used weight derived from the AMS remaining
    percentage, which the tare never entered into.

    Rows are identified by the signature of the broken lookup: added by RFID,
    carrying the weight of one of the other Bambu catalogue rows, with the
    weights read out of the catalogue rather than hardcoded so an install
    whose rows have been re-measured is repaired to its own numbers. Keying on
    whether a catalogue row had been recorded would not have worked -- the
    weight picker auto-selects the only row matching the weight and writes its
    id on the next save, so that column says only whether the form was ever
    opened. The one case that cannot be told apart is stated rather than
    hidden: someone who moved an RFID roll onto a genuine High Temp spool and
    set 216 g by hand is normalised with the rest. Runs exactly once, so a
    tare set afterwards is kept.
2026-08-23 14:25:45 +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
maziggy 7f8d79fe50 Split the Windows installer build so signing can wait for approval
The SignPath Foundation production certificate does not sign on demand
the way the self-signed test certificate does. Every request has to be
approved by hand in the SignPath UI, because the Foundation verifies
what is being signed and which build produced it. The submitting action
waits for that approval with a default timeout of 600 seconds, which is
ample while the test policy approves in seconds and far too short once
the wait is a person noticing a tag went out. A tag pushed at night
would have failed the run ten minutes later with the installer already
compiled and thrown away.

The compile now ends in its own job, which uploads the unsigned artifact
and exposes its id. A second job downloads it, signs it, and does the
release-facing work, with the wait raised to an hour and the job timeout
sized to sit outside it. Separating them is what buys the recovery: the
artifact is uploaded before the wait begins and is addressed by id, so a
missed approval window costs a re-run of the second job alone rather
than a rebuild. Raising the timeout in place would not have given that.

The second job runs for unsigned builds too. Daily prereleases are
deliberately left unsigned to preserve the signing quota, and gating the
whole job on the signing decision would have meant a second copy of the
alias, artifact and release steps for them to run through.

The decision itself moves into a named step that echoes it, so a tag
that came out unsigned can be explained from the run log rather than by
re-reading the expression. It is one source of truth feeding both jobs,
which a job-level env could not be.

Every step body is otherwise unchanged. The property worth keeping is
that none of the alias, upload and release-attach steps carry always(),
so GitHub skips all three when signing fails or times out and an
unsigned .exe cannot reach a release; that is now written next to them,
because it is easy to break by adding a condition without noticing.

The policy slug stays at test-signing and the signature check stays
lenient -- the test certificate is self-signed and reports UnknownError,
so requiring Valid would fail every run until the production certificate
is imported. Both are the cutover. The restructure behaves identically
under the test policy, the request simply completing at once instead of
waiting, so it can be proven green beforehand.
2026-08-23 11:19:33 +02:00
maziggy 1d011ecf60 Updated BACKERS 2026-08-23 10:59:41 +02:00
maziggy 7b181b84f0 Let a filled or foamed filament keep its own name (issue #2902)
The reduction that gave an AMS slot a material type read PLA-AERO,
PLA-GF, ASA-GF and PPS-GF as their base material, so a slot loaded with
foaming or glass-filled filament went out saying plain PLA or ASA. That
is worse than the bug it replaced. "PLA-AERO" matched nothing before,
which was useless but honest; "PLA" matches every PLA plate in the
queue, so the dispatcher would have sent one to filament that will not
print it -- and the contract the first fix claimed, that it could only
ever repair a slot, no longer held. @doncaruana caught PLA Aero on the
issue.

All four are values Bambuddy itself offers: filament_fields.json is the
material list the Profiles editor puts in a dropdown, and the reduction
table was assembled from the cloud filament names and the frontend
preset parser without ever being checked against it. It is checked now,
so the next type added to one and not the other fails a test rather than
a print. ASA-AERO joins them from the cloud catalogue (GFB02).

The table hyphenates because the slicers do, while a spool says "PLA
Aero" and every Bambu preset name says "Bambu PLA Aero". Adjacent words
are joined and taken when the join is a type exactly -- exactly, because
letting the prefix and suffix rules reach across a space would make
"Support for PLA" a type by its tail.

Also from @doncaruana, and the better half of his point: a preset is
chosen from a list the slicer defines, so it already knows its own type
and nothing has to be read out of a product name. The resolver now hands
that answer back and both assign routes prefer it. It cannot be the only
source -- material is required on a spool and slicer_filament is not, and
the spool this issue was reported for had no preset at all -- so the
reduction stays as the fallback for spools without one.

Two things had to move with it. The auto-unlink guard compared the
slot's reported type against the reduced material, so a spool whose
preset outranked its material column would have been unlinked from the
slot it had just been assigned to; it now accepts any type the assign
path could have written. And two lookups keyed by material took the
catch-all for a type they had no row for, which sent an ASA-GF spool out
at 200/240 -- too cold to extrude -- and preheated its chamber to
nothing. Both fall back to the base material last, so PLA-CF, PETG-CF
and PA-CF keep the rows they are listed with, and ASA-CF and ABS-GF pick
up ranges they had been missing all along.

What counts as a material name is decided by the base for the same
reason: saying yes throws the value away and rescues the slot from the
generic-material fallback, so the answer has to be no when that fallback
has nothing to offer. ABS-GF reduces to a generic ABS the printer can
resolve; PPS-CF reduces to nothing and is left as it stands. Adding a
type to the table therefore cannot quietly change that answer, which is
how these five slipped through in the first place.

------

Hand the bundled chamber-preheat table back the way it is read

Every lookup of the per-filament chamber map happens after the keys are
upper-cased, and the parser documents exactly that: keys uppercased,
DEFAULT always present so the resolution loop can index it
unconditionally. The three fallback paths returned the bundled constant
as declared, with the lowercase "default" row the Settings editor writes
and displays, so an install that had never opened the setting got a dict
the loop could not read its fallback out of and used a hardcoded 0 for
any filament without a row of its own.

It reported the right number only because that bundled default is 0.
Raising it would have changed nothing for everyone who had not
customised the map, with the map in Settings still showing the value
that was not being used.

The test that should have caught this asserted the fallback under either
spelling, and its docstring contradicted itself between title and
comment. It pins the contract now, over all four ways the parser can
fall back.
2026-08-23 10:36:50 +02:00
maziggy 0f3063aa1a Point the Watchtower recommendation at the maintained fork (issue #2917) 2026-08-23 08:55:07 +02:00
maziggy 20bf108e55 Updated BACKERS 2026-08-22 14:25:53 +02:00
Maksim Sadontsev ed85677913 Light the generated thumbnails so one model differs from another (#2816) (#2861) 2026-08-22 14:16:56 +02:00
maziggy 68140747f2 Post work PR #2853 2026-08-22 13:43:05 +02:00
Sean K 55cc64c87d Add printer video downloads and range selection (#2853) 2026-08-22 13:22:56 +02:00
sgiffhorn 937440f956 Report the selected plate on the archives API (#2796) (#2871) 2026-08-22 12:44:47 +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 88e8ca81c3 Give an AMS slot a material type, not a product name (issue #2902)
Assigning a spool wrote its material straight into the slot's tray_type.
A slot that says "PLA+" satisfies nothing that asks for PLA: not
OrcaSlicer, not Bambu Studio, and not Bambuddy's own dispatch matcher,
which compares the type the printer reports to the one the 3MF declares
as plain equality. The reporter's slot was unusable for every PLA plate
he had.

Not only a label, either. The same string went into the generic-filament
lookup, which missed, so the slot went out with no tray_info_idx at all
-- the half-configured state #2604 documents the printer as reverting
from -- and took the 200/240 catch-all nozzle range instead of PLA's
190/230.

PLA+ is not a special case. Bambuddy's own colour catalogue supplies the
material dropdown, and around forty of its values are vendor product
lines rather than filament types: HTPLA, PolyTerra PLA, PLA Matte, ASA
Extrafill, Flexfill TPU 98A.

So the four routes that configure a slot reduce the material to a name
the printer knows before sending it, and the product name moves to
tray_sub_brands -- which is where Bambu Lab puts it too: their catalogue
carries a preset named "eSUN PLA+" whose type is PLA. A name the
reduction cannot place is sent exactly as before rather than guessed at,
so this can only repair a slot, never break a working one. The spool's
own wording still leads the id and temperature lookups with the reduced
type appended behind it, so "PETG HF" keeps its own generic preset
(GFG96) rather than being traded down to plain PETG's.

Two guards decide whether a candidate filament id is really a material
name -- the resolver's, which discards one, and slot reuse, which will
not carry one forward. Both saw only bare types, so "PLA+" passed as a
filament id. They share one answer now, which also refuses to read an
id-shaped value: "GFPLA" ends in a material name, and reducing it would
throw away the calibrated preset in the slot.

One thing had to move with it. on_ams_change auto-unlinks an assignment
whose slot stopped looking the way it did when the spool was assigned,
and the check that spares a slot Bambuddy itself reconfigured compared
the printer's reported type against the spool's raw material. With the
slot now carrying the reduced type, every spool this issue is about
would have been unlinked from the slot it had just been assigned to.
Both sides are reduced there -- the printer's too, so slots configured
by an older version, still reporting "PLA+", keep matching.

Something starts working as a result: a slot holding a calibrated preset
is reused when a same-material spool is assigned to it, which could not
happen for these spools while "PLA" and "PLA+" compared unequal.

Reverting any one of the behaviours above fails a distinct test -- the
reduction's four matching rules and its pass-through contract included,
since that contract is what makes the rest of it safe.
2026-08-22 11:41:28 +02:00
maziggy cc39acfc74 Ask a printer that refuses FTPS what it actually said (issue #2780)
@grolmus measured a 9-printer farm and the numbers settle what this
failure is not. Reproduced here, three results:

  cleartext "421" banner on the TLS port
    -> [SSL: WRONG_VERSION_NUMBER] wrong version number (_ssl.c:1032)
  1.2-only server, client forced to 1.3
    -> [SSL: TLSV1_ALERT_PROTOCOL_VERSION]
  1.2-only server, an uncapped client
    -> negotiates 1.2 and connects

The first is byte-for-byte what the farm logs. So WRONG_VERSION_NUMBER
means the printer's first bytes were not a TLS record, a version
mismatch cannot produce it, and reaching a 1.2-only peer needs no cap.

What it still does not say is WHICH cleartext message, and that is the
part that would name the fault. OpenSSL has eaten those bytes by the
time the exception surfaces, so on this error the client now opens one
plain connection and reads them. The log then carries the printer's own
words -- an FTP refusal such as "421 Too many connections" would settle
it outright -- marked as the line to quote in a report. This gets the
answer from every affected install rather than from the one farm able
to take a packet capture.

Three things keep it from making the suspected fault worse:

- The failed socket is closed BEFORE the probe opens its connection.
  Holding a dead handshake open across a second connect to a printer
  that may be out of connection slots is the leak #2780's own cleanup
  was added to stop.
- It asks once per cool-off window, not once per attempt. Checked
  before the new deadline is written, so a live entry means an earlier
  failure already asked -- which matters because a dispatch ignores the
  cool-off (#2898) and reaches this branch four times.
- Connect and read share one timeout budget rather than getting one
  each.

Only WRONG_VERSION_NUMBER is probed. A protocol-version alert means the
peer did speak TLS, so there is nothing in the clear to read and the
probe would only sit out its timeout. A vsFTPd answering its connection
limit by accepting and staying silent -- the other half of the standing
theory -- arrives as a handshake timeout and lands on that branch
instead; there is a test saying so, because widening the trigger later
would look like an improvement.

The profile registry is corrected to what was measured. Its docstring
claimed "the P2S evidently does offer 1.3"; six P2S units refuse it.
Worse, the X2D (#1638) and H2C (#2582) entries were capped on the
reading that WRONG_VERSION_NUMBER came from a TLS-1.3 ClientHello,
which cannot happen -- so the cap is not what changed those outcomes
and both are now marked RE-TEST WANTED. They are kept rather than
removed: their reporters saw the symptom clear, nobody here has that
hardware, and the entry costs nothing on a printer that does not offer
1.3 anyway. The P2S entry (#1401) is a different symptom -- a 426
truncation mid-transfer -- and is the only one a session-ticket problem
could explain, though grolmus's firmware refuses 1.3 there too.

Both measurements are pinned by tests, so the explanation stays
falsifiable instead of becoming the next set of confident wrong
comments. Two existing cool-off tests now count two connections where
they counted one; the promise they exist for -- contacted twice, not
~110 -- is unchanged, and they say why rather than carrying a new
number.
2026-08-22 10:50:22 +02:00
maziggy 70ee53464d Say why an upload failed instead of blaming the SD card (issue #2899)
Every dispatch upload that failed carried one sentence: "Failed to
upload file to printer. Check if SD card is inserted and properly
formatted (FAT32/exFAT)." The reporter got it after a TLS handshake
failure and restarted the printer on the strength of it. That could not
have helped. The handshake never reached the printer's filesystem, and
the cool-off that made the next dispatch fail the same way lives in
Bambuddy's own memory, where power-cycling a printer does not reach.

because the advice was known not to work. It survived in the string
people actually read, so the failure mode that fix closed was still
reachable through the UI.
reachable through the UI.

The information was never missing. connect() separates five failure
classes and upload_file() separates 553/552/550, each with its own log
line -- 553 even logs a spelled-out list of storage causes -- and then
both returned a bare False. The dispatch had nothing left to work with
and guessed storage for all of them.

So the reason now travels with the result. The client records an
FtpFailure (kind, detail, and the reply code where the server gave one)
on every failure branch, and upload_file_async fills in a report object
the CALLER owns. Not a per-IP dict beside _mode_cache: those describe a
printer and are right to share, while this describes one operation, and
a background timelapse fetch running beside a dispatch would overwrite
the dispatch's reason with its own -- reporting the wrong cause with
total confidence, which is this bug again rather than a fix for it.

describe_upload_failure() picks the wording, and lives next to the kinds
so the two cannot drift. A 553 or 552 keeps the card advice, which is
the case it was written for, and quotes the reply code so a queue entry
and a support bundle line up. A handshake failure says the file service
answered without TLS and that the card is not involved. A refusal points
at the access code, a timeout at the network, and anything unclassified
says so and points at the log rather than picking a plausible cause. A
wrong instruction costs more than a vague one: it sends someone to work
on hardware that is fine. Nothing prescribes a power cycle.

The failure notification now carries the same sentence the queue shows,
instead of its own fixed "Failed to upload file to printer", so a push
and the screen cannot disagree about what happened.

Tests cover the classification, the report reaching the caller through
the retry loop, two callers not crossing, the queue entry itself, and
one line per message branch -- without that last one, deleting the
access-code or timeout branch left every other assertion passing, since
they only check that the card is not named and the generic message
satisfies that too. The card-advice test keys on the instruction rather
than the words "SD card", because the handshake message names the card
in order to rule it out.
2026-08-22 10:26:12 +02:00
maziggy 7c8f1f9435 Keep a dispatch's retries out of the FTPS cool-off (issue #2898)
A failed TLS handshake arms a 300s per-IP cool-off, and connect()
consulted it for every caller. A print dispatch retries after 2s, so
once the cool-off was armed all four attempts were answered from the
gate rather than the network, and every further job queued for that
printer failed the same way for the rest of the window. The reporter's
farm lost three jobs to one handshake error, with the retry budget
contributing nothing to any of them.

The gate was serving two callers that want opposite things from it. The
background sweeps -- the post-print 3MF, cover and timelapse fetches --
walk ~110 candidate paths against one wedged printer with nobody
waiting, and backing off for minutes is right for them. A dispatch is
one delete plus at most four upload attempts with someone watching a
progress bar. So the split is by caller: a client built with
respect_handshake_cooloff=False goes to the printer regardless, and the
dispatch's delete and upload -- and a firmware upload, same shape --
opt out. Everything else keeps #2780's behaviour untouched.

In the reported trace it is the pre-upload delete that takes the SSL
error and arms the cool-off, 8ms before the upload's first attempt, so
exempting the upload alone would have left one dispatch's worth of the
problem in place.

Callers that do respect the cool-off no longer sleep out a retry loop
against it: with_ftp_retry takes the printer's IP and stops at the
attempt that armed the gate, instead of spending three more attempts
and six seconds on connections that cannot happen. It also reports the
attempts it really made -- "failed after 4 attempts" for one attempt is
part of how this read as a network problem.

Two diagnosis fixes go with it. The cool-off skip was the one connect()
failure path that reported without naming its cause, and at DEBUG, so
four identical reason-free warnings were all the operator saw. It now
says at WARNING that nothing was sent and how long the printer has
left, once per cool-off rather than once per attempt -- not every
caller is gated, and a download-zip of 200 files would otherwise repeat
the sentence 200 times, which is the flood #2780 set out to stop.

And a dispatch that fails this way no longer tells anyone to check
whether the SD card is inserted and formatted -- nothing reached the
printer's filesystem, so the card is the one part of the machine that
was working. The message names the file service and rules the card out.
It is used only when a handshake failed during the dispatch itself,
read from the cool-off deadline MOVING rather than merely being armed:
the dispatch ignores the gate, so it can be running underneath one an
unrelated background fetch left behind, and blaming TLS for an upload
that really hit a full disk would repeat the mistake in the other
direction.

Tests count sockets rather than return values, since "returned False"
looks identical whether or not anything was attempted -- which is what
made the original report a log dive. Reverting any one of the five
behaviours above fails a distinct test.
2026-08-22 10:05:05 +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 4961990a7a Let a print start on a nozzle the printer can fetch (issue #2885)
The nozzle-diameter guard compared the sliced diameter against the
mounted hotends alone. On an H2C both hotends commonly read the same
size, so a job sliced for anything else was failed before upload with
"install the matching nozzle before printing" -- even with that nozzle
sitting in the tool-changer rack, which the printer fetches by itself
as part of starting a print. The reporter saw it as an asymmetry:
going to 0.4mm always worked, going to 0.2mm never did, and only
fetching the nozzle by hand on the printer's own screen let the print
run. It was never only about 0.2mm -- with a 0.6mm docked and 0.4mm
hotends, a 0.6mm slice was refused identically.

Placement is what made it fatal rather than merely wrong. The guard
runs near the top of _start_print and the rack picker near the bottom,
so the item was failed before the code that would have chosen the dock
ever ran.

Bambuddy had the rack contents the whole time. nozzle_info carries an
entry per nozzle -- ids 0/1 for the hotends, 16-21 for the docks --
each with its diameter, so the guard now tests the slice against both
sets. It keys off the ids rather than the printer model: only a rack
machine reports 16-21, which leaves no registry to keep in sync. An
empty dock is absent from the payload entirely, so an id appearing
there already means a nozzle is in it. stat is left uninterpreted; it
read 0 on every entry, occupied and empty alike.

A diameter in neither a hotend nor a dock still stops the print before
it uploads, and the message now names both sets so the machine's real
stock is visible.

The same telemetry showed a second fault, pointing the other way. A
hotend with nothing mounted is still reported, keeping the diameter of
the nozzle it last held -- measured at idle, where the rack-side hotend
read 0.4mm with max_temp 0 and serial "N/A" after parking its nozzle
back in the dock. That stale value counted as installed. Presence now
comes from the serial and the temperature rating, and emptiness has to
be stated rather than merely unstated: the serial must be the
firmware's explicit "N/A" and the rating absent. A firmware that
reports neither field normalises to exactly that, and reading it as
empty would switch the guard off on that machine.

Confirmed on an H2C with 0.4mm hotends and a 0.2mm in R6: the job
dispatches with nozzle_mapping [21], chosen by Bambuddy rather than
left to the firmware.
2026-08-22 08:46:07 +02:00
maziggy de74914db9 Updated CHANGELOG 2026-08-19 09:24:29 +02:00
gyrene2083 8d1daab23a Show K-profile value on AMS slot card (#2854) 2026-08-19 09:22:30 +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 dd50c51c1b Stop waking printers that cannot print the queued job (issue #2876)
The queue's smart-plug step chose a printer to switch on by model alone.
With a class-targeted job and every matching printer off, it walked the
farm in printer-ID order, woke the first machine with an Auto On plug,
and only then read the loaded filament -- so a job for a colour loaded at
the far end of the farm woke every earlier printer in turn and left each
one running until its own auto-power-off timer expired.

The colours were known the whole time. A printer keeps its last reported
AMS and external-spool trays after the power goes; mark_power_off blanks
connected and state and leaves raw_data alone. The wake step now asks the
same three questions the matcher asks a live printer -- required types,
forced colours, preferred colours -- of that reading, and passes over a
printer it rules out. A printer with no reading at all is still woken:
never having heard is not the same as nothing being loaded.

The matcher reports such a printer as needing filament rather than as
offline, so the waiting reason explains why nothing was switched on.

The manager now keeps a printer's last tray reading when it drops the
client, and the queue falls back to that. _power_on_and_wait calls
connect_printer in a retry loop, so an attempt that timed out used to
erase the reading the next one depends on. The record is kept beside the
clients, not inside one: the AMS merge is additive, so feeding it back
into live status would merge an unplugged AMS unit back in for good.
2026-08-19 08:26:43 +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 28781ea558 Keep the printer's name on statistics after it is deleted (issue #2873)
Every per-printer breakdown resolved the name against the printers that
exist now, so deleting a printer and choosing to keep its prints turned
"Ultron" into "Printer 1" in Prints by Printer, the success-rate and
time-accuracy lists, and Failures by Printer. Archives lose their printer
on that delete as well, so nothing was left to read a name from.

The runs themselves recorded the name they printed on. /archives/stats now
reports the last name each id was known by - taken from the newest run that
has one, so a later name-less row cannot blank it - and failure analysis
falls back to the same thing for ids with no printer left. The client keeps
preferring a live printer's own record, so a rename still shows up straight
away rather than after the next print.
2026-08-18 14:04:18 +02:00
maziggy cfecfa360e Restore the skip-objects list after a restart mid-print
The object list lives in PrinterState and is filled by the print-start path,
which bambu_mqtt suppresses on the first RUNNING push after startup so a
running print is not archived twice (#1304). Everything else that moment
restores came back - the archive into _active_prints, the filament
attribution session, the timelapse baseline - and the object list did not.
So the card saw zero objects and greyed out its Skip button for the rest of
the print. Measured on the maintainer's H2C: 8 objects loaded at 09:02, a
restart at 09:17, Skip dead for the remaining hour.

Nothing could bring it back either. GET /print/objects rebuilds the list
whenever it is empty, but its only caller is the modal that the greyed-out
button opens.

on_print_running_observed now reloads the objects from the archive of the
print that is still running, anchored on subtask_id - the firmware mints one
per print, so a leftover status="printing" row from a completion that was
never seen cannot lend its objects to another job. Without an id nothing is
loaded rather than guessed; the endpoint's own reload covers that on demand.

That endpoint now reads the archived 3MF from disk before it asks the
printer. The archive of a running print normally holds the very file the
printer is executing, so the fan-out was fetching back 15 MB Bambuddy
already had, over the printer's single FTP socket, while it was printing -
and on a printer that kept the file on internal storage it cannot succeed at
all. FTP stays as the fallback. skipped_objects is left alone: a reload is
not a new print, and what the user has already skipped only lives there.

The plate image had the same fault one layer down. Opening the modal asks
for the cover, the top view and the object-ID mask, and the in-memory 3MF
cache those share dies with the process - so after a restart all three went
back to the printer at once: three fan-outs, thirteen seconds, and a 0-byte
read from socket contention, which is the storm #972 was about. The cover
flow takes the running print's archived file too, resolved in the caller's
short-lived session and passed in so _produce_cover_image still does no DB
work, and marked as a shared file so the cleanup cannot delete the archive.

Finally the card: a running print always has at least one object, so a count
of zero means "not loaded", not "nothing to skip". Exactly one object is the
real nothing-to-skip case and still disables the button.

---

Stop a test's printer client leaking into the next test

POST /api/v1/printers really connects, so a test that creates a printer
through the API leaves a live client in the printer_manager singleton. The
singleton outlives the per-test in-memory database, so the next test on that
xdist worker - whose own first printer is handed the same primary key - reads
that leftover client as its own live status.

test_scheduled_drying_routes was the visible victim: an "online" printer with
no firmware version fails the drying preflight, so scheduling came back 400
instead of 200. It only bites when --dist load happens to put victim and
leaker on one worker, which is why it passes on its own and flakes under -n.

Registrations made during a test are now undone after it, ids the test did not
add are left alone, and disconnect_printer is what also drops the model and
printer-info caches and stops the paho thread the leaked client was keeping
alive against an unreachable address for the rest of the run.
2026-08-18 13:43:46 +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 05d87a9740 Release the plate-clear gate on a powered-down printer (issue #2864)
POST /printers/{id}/clear-plate answered 400 "Printer not connected" for
anything without a live MQTT client, and the printer card hid the button
under the same condition. With Auto Power Off that is the ordinary end of
every print: the reporter's log has printer 1 marked offline at 12:00:55
by the plug and the clear-plate POST rejected at 12:03:12, with the plate
already cleared by hand. Nothing could release the gate short of powering
each printer back on, clearing, and switching it off again.

Nothing in the clear path talks to the printer. set_awaiting_plate_clear
writes an in-memory set and the printers.awaiting_plate_clear column, and
that column exists precisely so the gate survives an Auto Off cycle
(#961). The guard came in with the endpoint in aa87e5598, copied from the
stop/pause/resume handlers beside it, where reaching the printer is the
whole point. The scheduler already reads the flag off a powered-off
printer - it refuses to wake one that is still gated - and the wiki
recommends the MQTT topic for automations because it does not depend on
the printer being powered on. Only the write path disagreed.

The card follows: showClearPlateButton drops the connection term, and the
expanded-view button - which lived inside the block that renders nothing
without a live status - is shared and given its own slot below it. The
bulk filter tests clearPlate before the connection filter; every other
bulk action still needs to reach the machine. The plate pill stays
connected-only, since its only render site is inside that same block.

Two things on the same path needed the flag without a client. GET /status
returned the schema default for a printer with no cached state - manually
disconnected, or not yet reconnected after a restart - reporting a clean
plate the database disagreed with, and hiding the control on exactly the
printers that needed it. And _emit_plate_clear_change bailed when the
printer info cache was empty, which would have left the retained
plate_clear topic (#2525) asserting "awaiting" after the gate was
released; it now falls back to the row.

This does not dispatch to an unreachable printer: _is_printer_idle still
requires a connection. Releasing the gate is what lets the queue switch
the printer on for the next job instead of passing it over.
2026-08-18 09:34:03 +02:00
maziggy 607b34e94d Check the card before writing a print off as internal-storage-only (issue #2856)
A print's dispatch says where the printer put the sliced file:
ftp://<name> for external storage, brtc://emmc/<name> for internal. Since
that there is then no file to find at any path.

That is where the printer chose to put it, which is not the same as where
port 990 can read it. The reporter's H2D - firmware 01.03.00.00, card in
the slot - reports brtc://emmc and keeps the same file under /cache: his
log has every print from 08-12 downloading from there, 19 MB included,
until the skip landed and two days of archives came out as a name and
nothing else. #2780's P2S and H2C really did 550 on every path, so both
are true and the URL alone cannot tell them apart.

So ask the printer rather than the model. The dispatch names the exact
file, which turns the question into one connection walking five
directories - against the sweep's ~110, which is the cost that made
skipping worth doing. A hit archives normally and is shared with the
cover endpoint; a miss keeps #2780's fallback archive and its reason, so
the archives banner still explains itself. Not probed when the printer
reports an empty slot, or while its file service is in TLS cool-off:
both have already answered the question.

The connection diagnostic asked the same question off the URL and warned
that the last print was out of reach. On this reporter's printer that
warning would have sent him to a setting that was already right, so it
now probes too - by directory listing, since the file it is asking about
can be tens of megabytes and the answer is a yes or a no. Capped at 6s to
stay inside the support bundle's per-printer budget, and "could not
check" leaves the warning standing.

The probe filename arrives over MQTT and becomes both a remote path and a
local temp filename, so names carrying separators, traversal or control
characters are declined rather than cleaned.
2026-08-17 14:50:40 +02:00
maziggy 5a05e03c8e Use the printer's own plug for energy when several are linked (issue #2859)
Per-print energy is one plug's meter read at the start of a print and
again at the end. Both readings asked for "the plug on this printer"
with scalar_one_or_none(), which raises on two rows. Linking a second
plug to a printer - a dry box, a filter fan, a lights script - therefore
stopped energy tracking on that printer outright, and did it silently:
the print-start handler logged the exception as an ordinary failure and
the print-end handler then reported "no start kWh recorded", which is
also what it says for a printer with nothing linked to it.

The assumption was never enforced anywhere else. The plug API rejects a
second Tasmota plug and deliberately allows any number of Home Assistant
entities, the UNIQUE constraint on smart_plugs.printer_id was dropped on
purpose, and every other consumer reads a list. These two call sites were
the last ones left from before that.

Energy now ranks a printer's plugs - the one that powers it first, then
by id so the start and end readings agree - and takes the first that
actually reports a counter, so accessories drop out with nothing
configured. Ranking rather than filtering: a printer whose only linked
row is disabled, or a script, used it before and still does. When none
of them measures anything the log names the ones it tried, so that stops
reading like "no plug configured".

Also: the plug page counted an online plug as offline unless it reported
energy, so a switch with no power sensor showed as offline for as long
as it stayed linked.

Existing archives cannot be backfilled - the starting reading was never
taken, so there is nothing to compute a delta from.
2026-08-17 12:17:12 +02:00
maziggy e9eff1a276 Point at the upstream issue behind the internal-storage behaviour
The README callout and the #2843 changelog entries described what Bambu
Studio does without saying it is tracked anywhere, so a reader had no
way to follow it. Both now link bambulab/BambuStudio#10481.

The #2843 entry also still blamed H2-series and P2S firmware for
keeping sliced files internally. That was the reading before measuring
it: the same printers archive in full when sliced in OrcaSlicer, so the
destination is the slicer's choice, not the printer's. Corrected here
rather than left to mislead, since 1.2.6b1 is unreleased.
2026-08-17 11:41:44 +02:00
maziggy 2c7df27d1a Look for a print's file when the printer says it is on the card
(#2780 regression)

A print of a file already on the printer -- a reprint from the
touchscreen, from Handy, or a slicer send-to-storage followed by a print
-- reports its location as a path rather than as a fresh upload:
file:///media/usb0/<name>. Since #2780 landed on 2026-08-14 Bambuddy read
anything that was not ftp:// as "the printer kept this internally",
skipped the FTPS sweep, and archived the print with a name and timing
only. Measured on an H2D: the file was listable and downloadable over
FTPS at the moment Bambuddy declared it unreachable. Before that change
those prints archived normally, so this is a regression, and it is not
confined to the H2 series the change was about -- an X1C reprint from its
own screen loses its thumbnail exactly the same way.

That module's own rule is to skip only on positive evidence, and a
file:// path is not evidence of internal storage. It now reads the path:
the printer's model cache under /userdata is a genuine skip, anything
else is unknown and sweeps, which is what it did before. Unknown rather
than external on purpose -- the empty-slot check still runs ahead of it,
so a file:// print on a printer with nothing in the slot reports the
missing card instead of sweeping for something that cannot be there.

The user-facing copy shipped this morning is corrected in the same
change, because it was written before we understood how Bambu Studio
actually chooses. Its Print button always uses internal memory; only Send
offers Cache or External, and that defaults to Cache too. So the advice
now leads with the two routes that take one step -- start the print from
Bambuddy, or slice in OrcaSlicer -- and offers Send-with-External and a
separate print start as the way to stay in Bambu Studio. The earlier
wording named no remedy at all and blamed the printer's firmware for a
choice the slicer makes. Banner and diagnostic, thirteen locales, README,
bundle rebuilt.
2026-08-17 11:24:53 +02:00
maziggy 034f8ac2c3 Tell H2-series owners what to do about incomplete archives (#2843)
The no-3MF banner and the connection diagnostic both explained why a
print archived with only a name and offered nothing to do about it. The
explanation was also wrong in a way that mattered: both blamed the
printer's firmware and said no setting changes it, which reads as
"your machine is broken and nothing will help".

Measured on hardware today. Same Bambu Studio, same model, three
printers a minute apart, all reporting the external-storage option as
on: the H2C and H2D went to internal storage and archived with a name
only, the X1C went to the card and archived in full. The same H2C and
H2D sliced in OrcaSlicer put the file on the card and archived in full,
and turning the option off changed nothing about that -- OrcaSlicer
always uploads over FTPS. So the printer does whatever the slicer asks,
and the option governs neither slicer on this generation.

Both strings now name Bambu Studio rather than the firmware, and end
with the two routes that do work: start the print from Bambuddy, or
slice in OrcaSlicer. Both mention that a card or stick is still needed,
because "use OrcaSlicer" on its own trades one confusion for another --
with an empty slot it refuses to send at all.

Translated in all twelve locales, each reusing the term it already uses
for the setting name and for slicer metadata. Bundle rebuilt, since the
strings compile in.
2026-08-17 10:48:10 +02:00
maziggy 89ea337612 Recognise our own print dispatch instead of guessing at a magic number
(#2843 follow-up)

Bambuddy records the project_file behind every print, because that command
names where the sliced file was put and so decides whether the archive can
have a thumbnail and slicer metadata at all. Its own dispatches were told
apart from a slicer's by testing sequence_id against "20000", on the
belief that 20000 was Bambuddy's alone.

It never was. 20000 is the slicer convention Bambuddy copied --
virtual_printer/bind_server documents the slicer sending exactly that
during detect -- and both slicers count up from it. Measured on the wire:
OrcaSlicer dispatched 20000 and then 20001, BambuStudio 20009 and 20010.
So whichever dispatch happened to land on the shared value was filed as
ours and never recorded, and after a slicer restart that is the first
print you send. The test was wrong in the other direction too: it called
every value above 20000 external, including ones we had sent ourselves,
which only stayed invisible because we always send exactly 20000.

Ownership is now established by remembering the job actually dispatched
-- sequence id, file, url and subtask name -- and consuming that marker
on the echo. One-shot deliberately: a slicer reprint of the same file a
moment later is somebody else's print and must not hide behind our last
one.

Nothing about printing or archiving changes. current_project_url and
ams_mapping are both captured before this branch and always were, so the
storage verdict that gates the FTPS sweep is untouched; two tests pin
that, because it is the part that would actually cost archives if it
drifted. What changes is that the diagnostic entry stops lying, and it is
the entry that tells an operator whether their printer stores files
somewhere Bambuddy can read -- which is the whole subject of #2780 and
would have to count, and an undercount there would have been silent.
2026-08-17 10:26:38 +02:00
maziggy 6488a33488 Stop a print with no 3MF borrowing another model's data (#2843)
H2-series and P2S firmware keeps a slicer-sent file on internal eMMC.
Port 990 serves external storage only, so there is no file to fetch, and
the print becomes an archive with no 3MF -- the ordinary outcome for
anyone who sends from Bambu Studio rather than through Bambuddy.
Confirmed on the maintainer's own machines: an H2C and an H2D both
dispatched brtc://emmc for the same model one minute apart, while an X1C
sent ftp:// for it. Three separate defects live in what happens next.

The first is the serious one. An archive with no 3MF keeps the path the
printer is executing as its filename, and on a sliced job that is always
Metadata/plate_1.gcode. The fallback that looks for the same model in the
Library or among earlier prints took its search term from there, so it
searched for `plate_1` -- a name every Bambu print in existence has --
and matched on a substring, so it also matched any name merely ending
that way. On the H2D a 1.6 g Cube resolved to lid_plate_1.gcode.3mf and
was costed at 207 g across three real spools. It was not confined to
plate names either: in the same database `Bank.3mf` matched "Piggo the
piggy bank", and `x1c.gcode.3mf` matched "slice-test-x1c". The matcher
now takes the model name the printer reports when the filename is only a
plate path, refuses a bare plate stem rather than searching for it, and
anchors to a whole filename with LIKE metacharacters escaped, because `_`
is a wildcard and model names are full of them. A print that cannot be
identified is now left untracked, which is the honest answer -- the
previous behaviour was to charge the operator's spools for a model they
had not printed.

Checked against every row rather than argued from the code. Across 273
library stems the result sets are identical. Across 241 archive stems 14
differ, all of them strictly narrower, and every dropped match is one of
the false positives above; all 233 archives still match their own
filename, so no legitimate donor was lost. Of the eight no-3MF archives
on that install the old matcher picked a wrong donor for two -- one of
them a calibration run that would have been charged the 207 g -- and the
new one picks none.

The second defect is that those archives could not receive a timelapse at
all. attach_timelapse derived its destination from the missing file's
path, and (base_dir / "").parent is the parent of base_dir, one level
outside the data directory. In Docker that is /app, so every attempt
failed EACCES and the scan retried and discarded the video 25 times over
twelve minutes, roughly a hundred FTPS connections for bytes that had
already downloaded successfully. Where that location happened to be
writable it was worse: the file landed beside the installation and the
attach then failed anyway, because the path could not be made relative to
base_dir. #1820 introduced a shared helper precisely so these derivations
could not drift apart, and this was the one site still doing it by hand.
The directory is created only after the filename has passed the traversal
check, so a rejected name still leaves nothing behind.

The third is silence. When a print's filament cannot be read from a 3MF,
the remaining-percentage delta is the fallback, and that needs a reading
at print start -- which a spool without RFID does not have until someone
sets a remaining amount by hand. Those slots were skipped with a bare
continue. Every other reason for skipping a slot in that loop is logged,
and the comment a few lines below argues the case explicitly: charging
nothing silently is indistinguishable from having nothing to charge. It
now says so, for slots the print actually used.

Four existing tests needed updating rather than the production path.
They patch backend.app.services.archive.settings by name, and the shared
helper reads its own module-level binding, so they kept the real data
directory and wrote outside tmp_path -- which is how the first draft of
this change littered a working tree. They now patch both bindings.
2026-08-17 09:26:13 +02:00
maziggy 28b2b9f151 Pin the PostgreSQL session to UTC so defaulted timestamps are UTC (#2855)
On a UTC+3 install every AMS humidity reading and every archive was
stamped three hours ahead of when it happened. Bambuddy stores naive
timestamps that hold UTC and the frontend's parseUTCDate reads an
offsetless timestamp as UTC, so the display added the offset to a value
that was already local.

The Python side has honoured that contract since #504. The reporter's
timestamps were not written by Python. Around ninety-six columns take
their value from server_default=func.now() and the migration DDL carries
another forty-nine on DEFAULT CURRENT_TIMESTAMP -- the database fills
those, and on PostgreSQL now() is a timestamptz, so storing it into a
timestamp without time zone casts it through the session TimeZone. A
Postgres container started with TZ=Europe/Istanbul bakes that zone into
postgresql.conf at initdb, and every defaulted column then receives local
wall-clock. recorded_at is the clearest case: nothing in the codebase
ever assigns it, so its value is entirely whatever the database decided.

Connections now carry timezone=UTC, which makes the cast a no-op whatever
the server is set to. Measured through the real engine factory against a
live PostgreSQL, a session on the reporter's configuration stored +10800s
and the fixed one +0s. Pinning the session was preferred over a hundred
and forty-five individual edits partly for its size but mostly because
half of those sites are raw DDL that no model-level change can reach.

SQLite needed nothing and gets nothing: its CURRENT_TIMESTAMP is UTC by
definition and it has no session timezone to get wrong, which is why this
survived two years of timezone fixes without showing itself. That also
makes it the reference -- the change moves Postgres onto SQLite's
behaviour rather than introducing a third convention -- so the SQLite
behaviour is now pinned by a test instead of being assumed. asyncpg is
the documented driver and takes the setting in its startup packet; any
other Postgres driver gets the same setting the libpq way, so a psycopg
URL does not fail at connect on a keyword asyncpg alone accepts.

Rows already written are deliberately left alone. The inverse cast is
computable and DST-correct, but it cannot be applied safely: created_at
is assigned explicitly on some paths and defaulted on others, an install
that began on SQLite holds correct and shifted rows side by side, and
nothing distinguishes them after the fact. Timestamps are right from the
upgrade forward and history keeps the times it was given.

One related mismatch goes with it, because fixing the database side alone
would have made it start lying on exactly the installs this repairs. The
support package's oldest_pending_age_seconds subtracted a naive local
clock from a naive UTC column, with a comment claiming it was UTC; on the
reporter's install the two errors cancelled. It reported a job queued
five minutes ago as three hours old east of Greenwich and a negative age
west of it. The two AMS and printer-sensor retention cutoffs move to the
same utcnow_naive helper -- correct in value already, but deprecated in
3.12 and emitting warnings on every sweep.
2026-08-17 08:05:02 +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 9c93884397 Stop auto K-profile calibration leaving an archive behind
With flow dynamics calibration on, the printer lays down a
pressure-advance line before the print itself and announces it over MQTT
through the same print-start event a real print uses. Bambuddy archived
it: a row named auto_pa_line_calib_mode marked as having no 3MF, sitting
in among the user's actual prints, with a Print Started and a Print
Completed notification for each one.

The printer's other internal jobs were already skipped, but only by the
/usr/ path they carry -- bed levelling reports as
/usr/etc/print/auto_cali_for_user.gcode. The pressure-advance line
carries no path at all. It arrives as a bare subtask name, so a rule
that only ever looked at the filename could not see it. Falling past the
guard it reached the no-3MF fallback, where print_name is subtask_name
or filename, and named the row after the calibration.

Internal jobs are now recognised by name as well as by path, from either
field, in one place both callbacks share. The match is exact after
normalising away the directory, one print-file suffix and case, rather
than a prefix or substring rule: "auto" and "calib" are ordinary words in
a user's own filenames, and a rule loose enough to catch some unnamed
future calibration would quietly swallow somebody's print.

The completion is suppressed too, and that half matters more than the
noise. With no archive to close, the completion falls into the
no-archive notification path -- which attributes an unmatched completion
to any queue item the printer finished in the last five minutes and
emails its owner. This calibration runs alongside a real print, so
silencing only the start would have told that print's owner their job
was done, early, and again for real later. The guard sits inside the
no-archive branch, so the plate-clear gate, the queue reconciliation and
the SD-card cleanup all still run; only the notification is skipped.

Skipping the run early also drops the FTP sweep that preceded the
fallback -- six candidate names across five directories with retries,
around a hundred connections looking for a file that cannot exist, aimed
at a printer that is in the middle of calibrating.

auto_cali_for_user no longer sends a Print Started notification either.
It is the same event about the same kind of job, and the archive was
never the only thing wrong with treating it as a user's print.
2026-08-16 14:01:33 +02:00
maziggy 61a6ed1f20 Move Camera View Mode from Settings onto the camera button
Whether a camera opened in its own browser window or as a floating
overlay was one dropdown in Settings > General > Camera, applied to every
camera on the install. Deciding per printer meant leaving the Printers
page, changing the setting, coming back, opening the camera, and going
back again to undo it -- five steps for a choice you make while looking
at the printer you want to watch.

The camera button on the printer card is now a split control. The icon
opens the camera whichever way you opened the last one; the caret beside
it offers both modes, and picking one opens the camera that way as well
as making it the mode the icon uses from then on. A menu that only
changed a preference would have left the user a second click to do the
thing they had already asked for. The mode in effect is ticked.

The choice lives in the user's own browser, so two people watching the
same farm can each have the view they want. camera_view_mode survives as
the default a browser that has never chosen starts from, and is written
back when the user holds settings:update. The local value wins on read:
someone below that permission cannot write theirs back, and a preference
that silently reverted on the next render would be worse than none.

Two things were consolidated on the way through. The popup-opening code
-- saved geometry, and deliberately no noopener so the browser copies
sessionStorage and its auth token into the new window -- was duplicated
between the printer card and the Cam Wall tile handler; it is now
utils/camera, with the geometry parse wrapped so a corrupt
cameraWindowState falls back to defaults instead of throwing, which
neither copy did. The Cam Wall follows the same remembered mode, since a
tile has no room for a split button of its own.

The effect that force-closed every open overlay when the setting flipped
to window is gone. It made sense for a global switch; with the choice
made per click, closing viewers someone deliberately opened does not.

No new locale strings: the four the settings control used are reused as
the menu's labels and tooltips and the caret's own label, so all 13
locales stay in parity untouched.
2026-08-16 13:43:15 +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 7a9b4921bd Stop the AMS temperature alert firing for heat the user asked for (#1802)
The alert compares against ams_temp_fair, the same threshold that colours
the printer card, which defaults to 35C. Drying deliberately runs at 45C
for PLA, 65C for PETG and up to 85C on an AMS-HT, and the alert repeats
once an hour for as long as the condition holds, so a twelve-hour dry
sent twelve notifications about a temperature the user chose. It then
kept sending them while the unit cooled back down, which is the half the
reporter confirmed on an AMS 2 Pro and an H2C.

Dispatch now consults the drying state the firmware already reports.
dry_time alone is not enough: it reads 0 through the cooling phase that
closes a cycle, so dry_status -- info bits 4-7, already parsed for the
drying-complete edge -- carries the rest. That constant moves out of
bambu_mqtt into a leaf util rather than being duplicated; drying_preflight
would have been the natural home, but it imports printer_manager, which
imports bambu_mqtt, and bambu_mqtt is one of the callers.

The cool-down afterwards is held by a latch released as soon as the unit
reads back at or below the threshold, rather than after a fixed delay, so
a 65C cycle in a cold basement and a 45C one in a warm room each get the
time they actually need. A two-hour cap bounds the one case the latch
cannot resolve on its own -- a unit that never returns below the
threshold -- and since such a unit would have been alarming with no
drying involved, releasing there restores the ordinary behaviour instead
of inventing a new alert.

Two exclusions are deliberate. Humidity is untouched, because during
drying that reading falling is the whole point. And dry_status 6,
HeatOutOfControl, is kept out of the active set: an AMS that has lost
thermal control is exactly when the alert should still arrive, so it must
never read as expected heat.

A cycle plus its cool-down outlasts a restart, so the latch is a settings
row rather than a dict beside _ams_alarm_cooldown -- the internal
timestamp-row pattern support.py already uses. It is read once per pass
and written back only when a unit changed it. Stamps ahead of now are
clamped on read, since a box whose clock jumps backwards writes them and
suppression is measured as now minus the stamp; without the clamp the cap
would measure from a moment that has not happened yet and hold the alert
quiet for the skew on top of it.

No new setting. The reporter was offered the opt-out checkbox they asked
for and said they would not want it if the alert simply never fired
during drying.
2026-08-16 11:45:46 +02:00