2075 Commits
Author SHA1 Message Date
maziggy ad3b5f1982 Map the P-series model codes to the right printers
C11 is the P1P, C12 the P1S, C13 the X1E and N7 the P2S, as the virtual
printer and a real P1P 3MF already say. The frontend map had them shifted,
so discovery pre-filled a P1S as a P1P and an X1E as a P2S. The backend
map read C11/C12 as X1C/X1 and lacked N7, the firmware check sent C13 to
the P2S line, and the capability lists never matched BL-P001 because
their lookup strips the dash.

-----

Post work PR #3134
2026-09-29 16:43:04 +02:00
Thomansky 1c316e2ad6 Answer the outcome prompt by reacting to the Telegram message (#3129) 2026-09-29 16:06:51 +02:00
William Faircloth d04514c842 Sync OIDC provider groups to BamBuddy groups on every login (issue #3107) (#3122) 2026-09-29 15:49:44 +02:00
Thomansky 71d4b2f70d Material number as a first-class spool field (#2994) 2026-09-29 15:11:50 +02:00
maziggy 926957f648 Post work-2 PR #2990 2026-09-29 14:54:36 +02:00
Thomansky 226826f3bd File Manager previews: fullscreen and zoom, image previews, double-click to open (#2990) 2026-09-29 14:27:19 +02:00
Kouki Ojima cb5f04d287 Give the Low Filament notification something that fires it (issue #2913) (#2940) 2026-09-29 13:50:52 +02:00
maziggy 2315b8fb5d Count camera reconnects in a row, not for the life of the stream 2026-09-29 10:28:41 +02:00
maziggy 3497cf0d46 Hold an automatic slot unlink until the slot stays empty (issue #3186) 2026-09-29 09:37:04 +02:00
sgiffhorn 89dde591f2 Resolve dispatch plate_id from the archive instead of assuming 1 (#2951) 2026-09-28 16:06:41 +02:00
Thomansky 053cfa73ad Suppliers as a managed list with per-spool assignments (#2996) 2026-09-28 15:14:07 +02:00
Kouki Ojima b6521e93f8 Let a spool keep its own empty weight (issue #2908) (#3011) 2026-09-28 14:48:04 +02:00
maziggy 63e987e591 Paint SpoolBuddy spools with their extra colours and effect (issue #3033) 2026-09-28 14:27:21 +02:00
maziggy 033eff1254 Show Bambu's own HMS descriptions and the real alert level (issue #2728)
Document HMS levels, Bambu's descriptions and uncounted faults (issue #2728)
2026-09-28 13:57:02 +02:00
Kouki Ojima a6ce26205a Give the AI's own halt a name in the archive (issue #2946) (#2954) 2026-09-28 12:51:43 +02:00
maziggy b04d753186 (Post work): parked AMS drying timers and translated drying failures (issue #2896) 2026-09-28 12:35:01 +02:00
M2ABRAMSTANK 6c0292b09a fix(ams): show a parked drying command as not started instead of an active cycle (#3096) 2026-09-28 12:09:59 +02:00
maziggy 61e2841d16 Correct the drying settings path and the print-drying comment (issue #2518)
The changelog named Settings -> Print Queue -> Auto-Drying; the toggle
lives in the Queue Auto-Drying card under Settings -> Workflow. The
scheduler comment and a test docstring called print_drying a permission
overlay rather than a trigger, but since #1816 it starts mid-print
cycles on its own regardless of queue state.
2026-09-28 11:40:21 +02:00
maziggy 0cc7cf0f36 Refuse null for number and on/off settings, and read bad rows as the default (issue #2518)
A PUT /settings with null for a number setting stored the literal
"None". From then on int()/float() raised inside the response builder,
and every settings read returned 503 until the row was fixed by hand.

Explicit null for any boolean or numeric setting is now refused with a
422 that names the keys, and nothing in that request is saved. A numeric
row that does not parse reads back as its default with a warning, so an
install that already stored one recovers. The typed-key lists moved to
module constants so the save check and the read path share them.
2026-09-28 11:32:02 +02:00
M2ABRAMSTANK 568702486b feat: Wait for sustained AMS humidity before an ambient auto-dry starts (issue #2518) 2026-09-28 11:10:23 +02:00
Kouki Ojima ff07a82358 Keep the consumed-counter reset out of Spoolman's own numbers (issue #2906) (#2939) 2026-09-28 09:58:16 +02:00
maziggy 6855d65d12 Let other applications send messages through the notification channels
POST /notifications/app-message delivers an app's message to every channel
with the new "Messages from connected apps" switch on (off by default),
through quiet hours, the digest and the log. API keys need the new "Send
notifications" permission, and their owner notifications:update; plain text,
http(s) links, 20 messages a minute per key. The electricity-price door and
this one now share one scoped-key check. /queue?batch=<id> opens and
highlights one batch order.
2026-09-27 12:45:50 +02:00
Adam Spice 858f8c772f feat: add optional printer model to stream overlay (#3099) 2026-09-27 12:04:05 +02:00
maziggy 37502719b7 Fix external-spool usage charged to an AMS spool (#3166)
Print commands carry the external spool as -1 in the flat ams_mapping
(the firmware rejects 254/255 there) and the real target only in
ams_mapping2. We captured only the flat list, so external-spool prints
looked unmapped and the usage tracker's position-based fallback
charged them to the first loaded AMS tray.

- Resolve external spools from ams_mapping2 when capturing a
  project_file (dual-nozzle keeps 254/255, single-nozzle -> 254)
- Tracker: an explicit -1 no longer falls back to a positional tray;
  a mapping naming no tray for any used slot defers to tray_now
- Keep the #1822 H2S tray_now override working with resolved mappings
2026-09-27 09:34:19 +02:00
maziggy b9e3fdc84f Post work PR #3047
fix(#1898): keep Telegram link previews, and keep the outcome prompt's failures its own

Four follow-ups to the post-print outcome confirmation merged in #3047.

Telegram: link previews were switched off for every message rather than
only for the outcome prompt, so a print_complete template carrying
{finish_photo_url} lost its photo preview whenever the photo was too
large to attach. _send_telegram now takes link_preview, and only the
prompt turns it off, as the Slack unfurl change already did.

Archives: Reset left the new Unconfirmed filter on, so the list stayed
narrowed and the button seemed to do nothing.

Print start: when the external-print check hit a failed statement, it
rolled back the caller's whole transaction, which expired the printer
and the just-created archive; on an async session the next read of
either raises, and the start notification, energy reading and timelapse
baseline were skipped. The check's reads now run in a savepoint, and a
failed flag write reloads the archive and printer after its rollback.

Print complete: a failed outcome prompt left the notification session
needing a rollback, so the per-user print email sent on it next failed
too. The dispatch now rolls back on failure.
2026-09-26 15:59:03 +02:00
Thomansky 44c7e6fb39 Post-print outcome confirmation: good/reject verdicts, one-tap links, yield stats (#3047) 2026-09-26 15:37:19 +02:00
maziggy 4e83751f71 fix(auth): let the connected-app consent page load inside Bambuddy's own frames
An app opened from the sidebar signs in inside Bambuddy's iframe, but every
page sent frame-ancestors 'none', so the browser refused to show
/connect/authorize there. That path now gets 'self', like the streaming
overlay: every ancestor must be Bambuddy itself, so foreign pages still
cannot frame it.
2026-09-26 13:37:02 +02:00
maziggy d56b48c499 feat(auth): connected apps - sign in to external applications with Bambuddy
Minimal OAuth 2.0 authorization-code flow with PKCE (S256): admins register
an app with one exact callback URL (Settings > API Keys > Connected Apps);
/connect/authorize asks for consent once and returns a single-use, 60 s code
bound to app, callback and challenge; POST /api/v1/connect/token swaps it,
with the client secret, for the user's identity and permissions. Codes and
secrets stored hashed, exchanges rate-limited per client and IP, no redirect
before the callback is validated, API keys cannot authorize, refused while
auth is disabled. i18n for all 15 locales.

-----

fix(db): upgrading from 0.2.4.0 or older no longer crashes at startup

The #2974 failure-reason conversion ran before the #1378 migration that adds
print_log_entries.failure_reason, so older databases stopped with "no such
column: failure_reason". It now skips a table without the column, only runs
where a legacy label exists, and on SQLite rebuilds archive_fts first, since
archives created before that index existed trip "database disk image is
malformed" when updated.
2026-09-26 12:51:53 +02:00
maziggy 7c16079ffa feat(queue): let a batch record the external order it fulfils
POST /queue/batches accepts external_source + external_ref; both are
returned on every batch and filterable on GET /queue/batches. The pair is
unique (index uq_print_batches_external), so a retried create answers 409
instead of queueing the same order twice. Migration covers SQLite and
PostgreSQL.
2026-09-26 12:18:40 +02:00
maziggy a36c0009a5 fix(diagnostics): name the stalled step when a bundle's connection check times out (issue #3164)
The support bundle gives each printer's connection diagnostic 15 s and
discarded the whole result on overrun, recording only "timed_out". A
14-printer farm's bundle carried that marker for every printer and
nothing else, so it could not say which check was slow.

run_connection_diagnostic now keeps an optional progress dict current
(finished checks + the step in flight). On timeout the snapshot records
stalled_in, elapsed_s and the checks that completed.
2026-09-26 11:14:25 +02:00
maziggy 86bd9e1bd4 fix(file-manager): harden the merged previews - PDF.js legacy build, server PDF thumbnails, STEP progress (issue #2976)
Follow-ups after merging PR #3128 (with the #2990 preview work):

- PDF preview failed on every browser without
  Map.prototype.getOrInsertComputed (Chrome < 145, Firefox < 144,
  older Safari): "This file cannot be previewed" for any PDF. Load
  pdf.js's legacy build, which bundles the polyfills for page and
  worker, and bundle the worker through Vite (?worker&url) so
  build.target lowers its class static block for Safari 16.0-16.3.
  The browser-baseline check only scanned .js output and never saw
  the copied .mjs worker; it scans .mjs too.
- PDF grid thumbnails only existed after someone opened the preview.
  Page one is now rendered server-side with PDFium (pypdfium2, new
  dependency, prebuilt wheels for every shipped platform) on upload,
  ZIP extraction and external-folder scans; Generate Thumbnails
  backfills PDFs as well. Renders are serialised behind a lock
  (PDFium is not thread-safe), run off the event loop, and scale
  from the page size so a huge MediaBox cannot allocate a huge
  bitmap. Unreadable PDFs land without a thumbnail and keep the
  browser fallback.
- A large STEP file takes over a minute to mesh in the browser and
  showed only a spinner. STEP loads now show "Converting STEP
  model... N s" and a note that large files can take a minute or
  more, in all 15 locales.
- Drop the two occt-import-js "externalized for browser
  compatibility" build warnings (path/crypto are only required in
  its Node branch); any other externalization still shows.
2026-09-26 09:51:52 +02:00
Thomansky 12dddada0a File Manager: external link, notes and photos on library files (#3128) 2026-09-26 08:56:48 +02:00
maziggy 1a84dfea5b feat(print-modal): show each printer slot's colour in the filament mapping (issue #3159)
The Print / Schedule dialog's filament mapping is where the colour a slice
asked for is compared against the colour actually loaded, and only the
left-hand side of that comparison had a swatch. The slot, and every slot in
its dropdown, was text -- and the text cannot be trusted: a slot's colour name
is resolved from the Color Catalog or, failing that, from hue, so a
third-party beige is announced as "Orange". A "Color mismatch" warning then
gives no way to tell a real mismatch from two names for the same hex without
opening the printer card in another tab, which on a farm swapping twenty or
thirty non-Bambu colours between machines is a check made many times a day.

Each slot now carries its colour and its hex, and the slot whose colour is
exactly the one the slice asked for is ticked. This works for a slot bound to
an inventory spool and for one configured through Configure Slot or on the
printer itself: the second kind has no inventory row behind it, and the
printer's own tray colour is then what draws. A bound spool contributes what a
tray record cannot -- SlotSpoolIdentity gains extra_colors and effect_type, so
a two-tone or glittery spool draws as itself rather than as its base colour.

The same treatment goes to the filament-override picker used for model-based
assignment. It is the same choice on the other dispatch path, and leaving it
text-only would have made one decision read two ways.

Both controls stop being <select>s to do it, because an <option> renders text
and nothing else. SlotPicker keeps what the select gave for free -- arrow,
Home/End, Enter and Escape keys, listbox semantics, and the border colouring
that encodes match, same-type-different-colour and not-loaded -- and is
portaled with position:fixed so it is not clipped by the dialog's own scroll
container, flipping above the row when there is no room below.
2026-09-25 11:38:13 +02:00
maziggy 6e8d543d2a fix(ams): stop showing the humidity drop index as a percentage (issue #3140)
Bambu sends two humidity fields that are not the same quantity.
humidity_raw is relative humidity in percent; humidity is a 1-5 drop
index, and it runs the other way -- OpenBambuAPI's push_info sample
pairs "humidity:30%" with "humidity_idx:4", so a high index means dry
where a high percentage means wet.

Four call sites used the index whenever no percentage arrived. A unit
sending only the index therefore rendered as "2%" in the green band
while being the second-wettest of the five steps, charted an average of
index values as a percentage, and sat under every humidity threshold
forever, since no index can reach one -- the alarm and auto-drying could
not fire for such a unit at all.

- utils/ams_humidity: one leaf helper, a percentage or None. The index
  is never converted; None is what every caller already handles.
- routes/printers, printer_manager, print_scheduler, main, bambu_mqtt:
  all five readings go through it, so the card, the websocket, the
  chart, the alarm and auto-drying cannot answer differently.
- main: a unit that reports the index and no usable percentage says so
  once per unit in the log, with its firmware versions requested. No
  supported printer is known to do this, and "known" is doing work
  there -- the alternative is a card that goes blank with no trace.

Three faults found while checking what else those paths touched:

- main: humidity_raw=float(x) if x else None stored NULL for a numeric
  0% while writing 0.0 to humidity on the same row.
- main: that same expression was unguarded, unlike the parse above it,
  so a non-numeric humidity_raw raised inside record_ams_history and
  aborted the pass for every printer, not just the one that sent it.
- routes/ams_history: the averages were tested for truthiness, so a
  window averaging exactly 0 reported no average while the min and max
  beside it reported 0.0.

An affected unit now reports no humidity rather than a number that means
the opposite: the indicator is hidden, the chart leaves a gap, the alarm
and auto-drying skip the unit. Temperature is untouched. Auto-drying's
outcome is unchanged either way -- an index could never cross the
threshold -- so only the intent moves.

No supported printer is known to be affected; the report came from an
install running X1Plus, which Bambuddy does not support. Verified
against 7927 recorded samples from seven AMS units including an AMS-HT:
not one used the fallback. Two percentages that did fall through to the
index no longer do -- a reading with a decimal point, and "38.0", which
int() rejected.
2026-09-24 11:59:30 +02:00
maziggy fc6b953816 fix(virtual-printer): give each install's CA a name of its own (issue #3014)
A slicer holding the CA of two Bambuddy installs could only connect to
one of them. Each CA worked on its own; together, one stopped, with the
generic "Connect ... failed! [SN:..., code=-1]" that an install whose
CA was never imported gives.

Every install signed as exactly CN=Virtual Printer CA. A slicer's trust
store is a flat list of certificates and OpenSSL resolves an issuer by
Subject DN: it takes the first authority whose name matches and fails
the chain when that one turns out not to have signed the certificate,
rather than trying the next match. Whichever CA landed second in the
file lost -- decided by nothing but the order they were appended in.
Reproduced with openssl verify against a bundle holding two CAs: the
first leaf verifies, the second fails with "certificate signature
failure".

- certificate.py: a newly generated CA takes a suffix from its own key
  identifier (CN=Virtual Printer CA D55808BE) and publishes that
  identifier, which the printer certificate points back at.
- Existing CAs are untouched, so nothing has to be re-imported. A
  printer certificate signed by one keeps exactly the shape it has
  today: the authority key identifier is added only when the CA has an
  identifier to name.
- tests: unique names per install, the identifier reaching the leaf,
  an existing CA being reused unchanged, and both chains verifying
  through openssl from a single trust store.

The collision goes away as soon as one of the two CAs is newer than
this change. Two installs that both predate it still collide until one
has its bbl_ca.crt/.key deleted and regenerated, which is a re-import
for that one -- documented in the wiki.

Reported by @Steven-Pierce.
2026-09-24 11:22:40 +02:00
maziggy 1b20d1a968 fix(notifications): send the ntfy priority the dialog was collecting (issue #3139)
The per-event Priority header from #990 never reached ntfy. The dialog
builds its rows from the provider's event toggles and stores the map
under those names -- on_print_complete, on_print_failed -- while every
sender is called with the bare event name, print_complete. The lookup
missed for all 18 events the dialog offers, so every notification went
out at the ntfy server's default with the configured priority sitting
untouched in the database.

Both ends looked healthy, which is why it shipped. The stored config
held exactly what was set, and the tests were green because they called
_send_ntfy directly with the prefixed name -- the one spelling the
running system never produces.

- notification_service.py: accept either spelling, bare first, so
  existing configs keep working and nothing needs migrating.
- schemas/notification.py: document both key forms, and which one the
  UI writes.
- tests: use the bare names, and add one that runs from a finished
  print through to the outgoing request. Without the fix it fails on a
  header dict holding only Title, which is the assertion that was
  missing.

The daily digest is unchanged: send_digest sends with no event_type at
all, and the dialog offers no priority for it -- it is one message for
several events.
2026-09-24 10:00:33 +02:00
maziggy 58ea7a360d refactor(models): break the schema cycle that backup and restore sort through
print_archives.library_file_id -> library_files.folder_id ->
library_folders.archive_id -> print_archives. Three nullable SET NULL
links, each reasonable alone, that together made a loop
metadata.sorted_tables could not sort: it dropped those edges, warned on
every backup and every restore, and could return an order placing a
child before its parent -- which once imported library_files ahead of
library_folders and killed a restore on a ForeignKeyViolation.

The restore no longer depends on that order (it strips every foreign key
before importing and adds them back after), but the backup export sorts
the same way, and the warning ends with "may raise an error in a future
release" -- which would break backup and restore on one upgrade.

Marking one edge use_alter removes it from the sort graph, not from the
database: PostgreSQL emits it as ALTER TABLE ADD CONSTRAINT, as it
already did for every constraint on these three tables, and SQLite
inlines it into CREATE TABLE, so ON DELETE SET NULL holds on both.
Verified against PostgreSQL 16 and SQLite.
2026-09-23 16:58:14 +02:00
maziggy 0eb083b32e fix(slicer): read the plate model once per part for the post-slice thumbnail (issue #3135)
The post-slice thumbnail loaded the sliced 3MF with trimesh, whose reader
mishandles the Bambu Studio / OrcaSlicer layout: for every component that
references a file in 3D/Objects/ it re-parses that file and appends all
of its meshes again. N copies of a part came back as N^2 copies of its
triangles, and each component carried every other part of the same file.
25 bins of 10k faces loaded as 6.4M faces; the render took 54 s and
8.4 GB on the event loop, and the server was OOM-killed.

Parse the 3MF directly (lxml, entities/DTD/network off, streamed and
freed element by element), walk objects, components and build items
(p:path on either) with their transforms, and keep each mesh once.
Decimate per unique mesh to its share of a face budget before placing
instances, flip winding on mirrored placements, and skip the thumbnail
above a face ceiling checked both on what decimation can reach and on
what it delivered.

Both slice routes run the render in a thread. The renderer uses
matplotlib's Figure/Agg API instead of pyplot, whose process-global
figure state let a threaded plate render and stl_thumbnail's
event-loop render lay out and close each other's figures.
2026-09-22 10:47:00 +02:00
maziggy 89d94796ee fix(queue): keep the filament override when a model job moves to one printer (issue #3133)
Switching an "Any P2S" job to a specific P2S cleared its filament
override: "Specific Printer" empties the target model and the reset
effect counted that as a model change. Printer mode also matched trays
against the 3MF's colours, never sent the override, and left the old one
on the row.

The reset now compares against the last model actually targeted, so the
switch keeps the override while a real model or plate change still clears
it. Printer-mode tray matching (single, per-plate, multi-printer and the
selector's per-printer editor) runs against the requirements with the
overrides applied, mirroring the scheduler's _apply_filament_overrides; an
entry naming the slot's own filament is not a swap and keeps its
tray_info_idx. Printer-mode submits carry the user's overrides, and the
create endpoint stores them for a printer-targeted job, so a dispatch-time
recompute of an unresolved mapping looks for the same filament.

Saving re-attaches the tray_info_idx an unchanged entry already had, so a
virtual printer's force-colour PLA-variant pin (#2650) survives an edit in
either assignment mode. The printer card's compatibility filter skips
printer-targeted jobs: it mirrors the model scheduler, and hiding a job on
filament would hide it from the printer it is going to run on.
2026-09-22 09:45:56 +02:00
maziggy f98381f3d1 fix(queue): send the copy count for a cross-model print (issue #3101)
Selecting sliced files for two printer models and asking for 25 copies
queued one item. The queue emptied as soon as it dispatched and the
Batches tab stayed empty, because no batch is created at quantity 1.

A multi-plate file moves the run count off the modal's Quantity field
onto a stepper beside each plate (#342), hiding the field. The
cross-model submit (#671) posts that field, which in this combination
nothing can set, so it stayed at its initial 1. The modal read "19 runs
in total" above a button that queued one.

Per-plate steppers do not fit a cross-model job: its plate is chosen per
candidate, in the alternatives list, so there is one number to give.
Exclude cross-model from the per-plate mode and the global field comes
back.

Drop the plate selector in that mode too. Its choice never reached the
request; it only keyed the filament-requirements query, so picking plate
3 for a candidate while plate 1 stayed ticked above produced overrides
computed from a plate the job would not print. That query now follows
the primary file's own dropdown.

Dispatch needed nothing -- it already gives each copy its own candidate
rows -- but naming did. A cross-model job carries neither archive_id nor
library_file_id, because the candidates are the files, so both branches
that name a batch missed and every such order would have read "Batch" in
the tab the reporter went looking in. Name it after the first candidate.

The existing cross-model tests all mock a single-plate file, which is
why the pair was never covered; the multi-plate case is added.
2026-09-21 15:26:09 +02:00
maziggy ebc72e1d41 fix(archives): keep the project name when the wrong-plate guard rejects a 3MF (issue #3126)
Bambu Studio files a sliced print on the X2D's internal eMMC, which FTPS
does not serve. The bounded probe found a same-named file on the card --
an earlier slice of the same project, plate 4, against a running plate 1
-- and #1204's guard correctly refused it rather than archive another
plate's thumbnail, filament and cost.

It then blanked subtask_name because swap_plate_suffix returned None. But
None also means "this name carries no plate suffix", and such a name holds
no stale plate number to be wrong about. The project name was dropped, the
row fell through to the gcode_file path, and the archive was titled
plate_1.

Keep the name for the title only. subtask_name itself stays disowned,
because it is what every lookup here is built from and it keys
_active_prints, where the cover endpoint's own download of that same name
would find this archive and hand the contradicted file to
_recover_fallback_archive -- which checks a candidate is a readable 3MF
and never which plate it holds. A corrected name is still registered:
that one points at the plate actually running.

Also name the X2D alongside H2-series and P2S in the Archives banner, the
connection diagnostic and the storage-verdict docs -- it stores slicer
sends the same way, and an X2D owner was told the explanation did not
apply.
2026-09-21 11:18:35 +02:00
maziggy 8f9d79ccf0 Post work PR #? 2026-09-20 12:21:00 +02:00
Pascal Heidmann 036f0a688f Merge pull request #2845 from pascalheidmann/refactor/modular-import
(Refactor): modularize import ("Makerworld tab")
2026-09-20 12:12:56 +02:00
maziggy 0db028f9e6 fix(inventory): one structured 409 for a tag another spool holds (issue #3110)
The two tag-link routes answered the same conflict differently. The
built-in one said "Tag UID already linked to another active spool" and
named nobody -- while holding the conflicting spool row it had just
loaded -- and Spoolman mode named the spool inside a different English
sentence. Neither was machine-readable, so a client had to parse prose
to learn which spool to look at, and could only do it in one mode.

Both now raise one shared constructor: code tag_already_linked, the
holder's id, and which identifier collided. That is the detail shape
insufficient_filament and printer_connection_failed already use, so
ApiError parses it with no frontend change.

Two active spools can carry one tag -- no unique index on either
column, no conflict check on PATCH /spools/{id}, and /spools/bulk
copies one payload including the tag into every row it creates -- and
the lookup read that with scalar_one_or_none(), which raises on two
rows. The exception escaped into the auth middleware's fail-closed
handler, so the caller was told the authentication service was
unavailable. Both lookups are now ordered and take the first row, as
get_spool_by_tag earlier in the same file always has.

Naming the lowest id means the Spoolman scan reads every row where it
used to stop at its first match, so it now reads extra.tag defensively:
that field is edited outside Bambuddy, and a single null further down
the list would otherwise take the request down in place of the 409.

The kiosk reads the new code: a refused link showed a flat "Failed to
assign spool" and now names the spool holding the tag, reusing the
inventory.tagAlreadyLinked key that no code referenced.
2026-09-20 11:49:00 +02:00
maziggy 905bda4f3f fix(ams): read the firmware presence bit, not the tray state (issue #3084)
Swapping a Bambu spool for one the AMS cannot read left Assign Spool
publishing no ams_filament_setting at all. The printer kept showing "?"
on its screen and in the slicer, and only Configure, which publishes
unconditionally, put anything there.

Four places asked the tray's `state` field whether a spool was in the
slot. It cannot answer that. An AMS-HT reports its LOADED tray as 9
rather than 11, because it does not feed into a shared buffer the way a
4-slot AMS does -- the merge has skipped its own state heuristic for HT
units since #2594 for exactly this reason. And the field is partly our
own writing: apply_tray_exist_bits stamps state=9 on every slot whose
tray_exist_bits bit is 0, and when the bit comes back it refreshes only
the `exists` annotation beside it. Either way the slot sits at
exists=True, state=9 until something configures it.

That 9 also kept the deferred-configuration replay from firing -- its
own "has a spool appeared" test was the same heuristic -- which is the
deadlock #1322 removed from the assign path, still in place one step
further along. And it is what deleted the assignments in #3100: with the
replay never firing, the row kept the empty fingerprint it was stored
with, and the first tray report naming a filament was read as a swap.

All four now read tray_exist_bits first, which is the mask firmware
answers this question with and the one the printer card has drawn its
"?" from since #2527. The bit is allowed to overrule an "empty" state
and nothing else: a bit reading empty deliberately does not start
suppressing pushes that go out today, because the cost of computing a
bit position wrong is a slot that silently stops configuring, against a
saving of one message firmware would have dropped.

A blank tray report from a slot the bit calls occupied no longer unlinks
anything, off a print as well as during one, in both inventory modes --
Spoolman's parse_ams_tray calls a tray with no type empty, so a tag-less
spool assigned through the UI had its row deleted by the first idle push
after it went in. A filament the AMS cannot identify is not a filament
that was removed.
2026-09-20 11:13:20 +02:00
maziggy 4a85e033c0 fix(finance): show the currency the install is configured for (issue #3123)
The Finance page was the only surface in Bambuddy that read its currency
from a data row rather than the `currency` setting, and it fell back to EUR
where every other page falls back to USD. One variable drives every amount
on that page, so the personal balance, the cost-center budgets and the whole
transaction list were wrong together on any install not set to euros. It now
takes the configured currency from /settings/ui-flags, which is readable by
anyone who can see Finance -- /settings needs SETTINGS_READ, which a
cost_centers:read_own user does not have.

The backend was the other half. Of the four places that settle on a
currency, three wrote a hardcoded "EUR": the wallet the API mints on demand,
the wallet a print charge mints when none exists, and the balance returned
for a user with no wallet row at all. All four now go through one resolver,
which lives beside the rest of the balance logic.

The wallet's currency column is removed outright rather than merely ignored.
An install has one currency and nothing here converts between them, so a
per-wallet copy could only ever drift from the setting -- and a column
nothing reads is a trap for whoever finds it next. A startup migration drops
it on both SQLite and PostgreSQL, after the raw CREATE TABLE that would
otherwise re-add it on an install whose finance tables predate the ORM.
SQLite builds older than 3.35 have no DROP COLUMN and keep it, harmlessly,
since it has a default and no reader.

Saving settings now invalidates the ui-flags query too. Nothing did, so a
changed currency sat behind that query's staleTime before showing up. The
sponsor prompt's own EUR fallback is now USD, matching AppSettings.
2026-09-20 10:06:47 +02:00
maziggy 74173eab8d fix(vp): offer every host IP as a bind target, not one per adapter (issue #3121)
Each enabled virtual printer needs its own IP address, and the documented
way to get several is to add secondary addresses to the adapter already in
use. Linux reads those back through `ip -j addr show`, which reports every
address. Windows and macOS have no `ip` command and fell through to a psutil
enumeration that stopped at the first IPv4 of each adapter, so a host with
three addresses on one NIC offered exactly one bind target and the second
virtual printer could only fail with "Bind IP ... is already in use".

The psutil path now collects every address, marking the ones after an
adapter's first as aliases the way the iproute2 path does. Callers that want
interfaces rather than addresses -- the discovery scan and the support bundle
-- project the primaries back out, so their view is unchanged.

The ioctl fallback that a Linux host without iproute2 used to get is now
reached only when psutil itself is missing, which gains that host aliases
too. The interface-name exclusions stay Linux-only: they are Linux device
names, and a Windows adapter called "Local Area Connection" matches the "lo"
prefix.
2026-09-20 09:32:22 +02:00
maziggy 1ccaf74dd5 fix(install): sign the Python that macOS grants local network access to (issue #3114)
macOS attributes Local Network permission to a code signature and judges a
launchd-spawned process on its own, rather than letting it inherit the grant
of the Terminal that started it. Homebrew ships Python unsigned on Intel, so
there is no identity for the grant to attach to: every connection to a LAN
address is dropped with no error the application can log and no permission
prompt. The printer reads as unreachable and nothing says why, and the entry
in Privacy & Security cannot be made to work because it refers to an identity
that no longer resolves.

install.sh signs during a macOS install; update_macos.sh re-checks on every
update, because `brew upgrade python` installs a fresh unsigned binary under
a new versioned path.

Both sign only what is currently unsigned. That gate is load-bearing: on
arm64 the linker ad-hoc signs every binary and the identity is a hash of the
file, so re-signing would rotate it and revoke a working grant on each update.
A python.org build carries a real Developer ID and must not be downgraded for
the same reason.

The interpreter and the framework's Python.app are both signed. The first is
what sys._base_executable resolves to and what the reporter's TCC log names;
the second is what his fix actually targeted. Which one macOS attributes
could not be established from either, and signing both costs nothing.

-----

fix(diagnostics): name the macOS permission that silently blocks the printer (issue #3114)

The port checks reported all three ports unreachable while the subnet check
passed, and port_mqtt's fix text sent the reporter after firewalls and IP
addresses. On a macOS native install that pattern has a cause neither of
those covers: no Local Network grant, denied with no error and no prompt.

A new macos_local_network check, appended on macOS only so no permanently
dimmed row appears for anyone else. It passes when the control port answered,
which is proof the permission is in place and means the signature probe never
runs on a healthy diagnostic. Otherwise it probes the interpreter: an
unsigned one gets the repair that fixes it, a signed one gets System Settings
— the arm64 case, where the identity is a hash of the binary, so a Python
upgrade presents macOS with a new application and strands the old grant.

Always warn, never fail, and only once port_mqtt has already failed, so this
can never be why a green diagnostic turns red. A printer that is simply
switched off produces the same all-ports-dead pattern, which is why the
signature, not the pattern, is what earns the specific advice. An
undeterminable signature is reported as the generic case rather than as
unsigned: that advice rewrites a file in the user's Python installation and
must not be offered on a guess.
2026-09-19 16:03:52 +02:00
maziggy 70b42d1c8d fix(library): stop reporting success for a bulk add that queued nothing (issue #3112)
POST /library/files/add-to-queue reported every per-file rejection in an
errors array and returned 200 regardless. A caller that checks the status
code saw a successful request, no visible failure, and no queue item.
That is a 400 now when nothing at all was added, with the same reasons in
the body. A call that created some items still succeeds, because it did.

The items it created were aimed at nothing. The route always wrote
printer_id=None with no target_model, and the scheduler dispatches on one
or the other -- so those rows matched neither branch and could never be
picked up by anything. They sat in Unassigned until someone opened each
one by hand.

The request takes an optional printer_id or target_model for the batch,
and with neither it aims each file at the model its own G-code declares.
Only when a printer of that model is active: owning no H2D is the user's
situation rather than their mistake, so the file still queues as the
unassigned row it has always been, rather than gaining a target nothing
can answer.

Three gates POST /queue/ has applied for a while now apply here too,
because an item reaching the scheduler through this route has to be as
printable as one reaching it through that one: the cross-model check that
stops a file sliced for one printer being dispatched to another (#2578),
the filename check that would otherwise surface as a failed upload hours
later (#1540), and the filament requirements the scheduler matches before
handing a model-based item to hardware.

Nothing inside Bambuddy calls this endpoint -- the Library's own Print
action goes through the queue API with a printer already chosen -- which
is how it came to drift this far from it.

-----

fix(library): scope add-to-queue file reads to the caller

The bulk add resolved its files by raw id. Every other read in this
module goes through the ownership gate, and so does the single-item
queue path; this one did not.

Invisible rows are dropped before the loop, so they report as the plain
"File not found" an unknown id already gets.
2026-09-19 12:51:19 +02:00
maziggy 3a5f802cdc fix(diagnostics): read the subnet the host is actually on (issue #3092)
The Network subnet check told the reporter that 192.168.98.170 and
192.168.96.9 were on different networks and to go configure routing
between them. They are four hundred addresses apart inside one
192.168.96.0/22 LAN.

An IPv4 address does not carry its prefix, and the check supplied /24
for both sides. That is the most common LAN and not the only one, and
the guess is wrong in both directions: it splits a /22 and it merges a
/25. Read the prefix off the interface that owns the address instead.

find_local_ipv4_network() enumerates every interface, including the ones
EXCLUDED_INTERFACE_PREFIXES hides. That list keeps docker0 and friends
out of the Virtual Printer's bind dropdown; here the caller is asking
about an address the kernel has already picked as a route source, and
answering "unknown" because it sits on a bridge would be a worse answer
than the truth. When nothing claims the address the check skips, which
is what it always did with no host IP at all -- it must not assert a
split it cannot see.

The same check chose which of Bambuddy's own addresses to compare by
probing a route toward 10.255.255.255, which on a multi-homed host is
not the interface the printer is on. It asks for the route toward the
printer now. On a two-NIC dev box that alone was warning about a printer
sitting on the second card's own subnet.

The probe takes IPv4 literals only. connect() on a name would resolve
it on the event loop, and _same_subnet rejects names anyway, so nothing
is lost. Resolving the prefix shells out to `ip -j addr show`, so it
moves off the loop too.

-----

fix(diagnostics): name the container engine instead of asking about Docker (issue #3092)

"Not running in Docker - not applicable", said to a Bambuddy inside a
Podman container. It reads as "you are on bare metal", and it sent the
reporter looking for his problem somewhere else.

Podman runs Bambuddy in exactly the two shapes Docker does, and the
shape is the thing that breaks printer discovery and the Virtual
Printer. detect_container_runtime() names the engine -- Docker, Podman,
Kubernetes, containerd, LXC, or a container it cannot place -- and the
check became Container network mode.

is_running_in_docker() is deliberately left alone rather than rewritten
on top of it. Three callers key real behaviour off that flag, and one of
them switches the Add Printer flow from SSDP to subnet scanning. SSDP
works for a host-networked Podman container, so answering True there
would take a working feature away to fix a sentence. Widening it is a
separate decision from naming the engine, so it is made separately.

Mode detection keeps the original signal first, which also makes the
Docker path incapable of regressing: a Docker host always has a docker0,
so a container that sees one shares its namespace, and the new rules can
only turn a warning into a pass. That signal says nothing about Podman,
which creates no such interface on a host running no bridge containers --
which is how host networking came to be reported as bridge. The general
form of the same idea answers for Podman: an interface whose iflink
equals its ifindex was created in this namespace, and a NAT-networked
container only ever receives one end of a veth pair. tun/tap is skipped,
because a container may run its own WireGuard and that tun is native to
a namespace it is not evidence of. The interface also has to be the one
the kernel just named -- sysfs is namespace-tagged but a bind-mounted
host /sys is not, and reading a colliding name's numbers would be
reading another namespace's answer.

What is still unreadable now says so and suggests host networking if
discovery is failing, rather than guessing bridge and telling a healthy
install to recreate itself. An LXC or LXD system container is named and
told the question does not apply: it is on the LAN like a small virtual
machine, so there is no network mode to recommend -- and its subnet
check still runs.

An engine we cannot name is a sentinel the frontend localizes, not a
word interpolated into thirteen other languages.

The support bundle carries the engine name beside the Docker flag, so
the next report of this shape is answerable from the bundle.
2026-09-19 12:19:19 +02:00