Commit Graph
4355 Commits
Author SHA1 Message Date
maziggy 9fb878cd3e 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-30 16:13:18 +02:00
maziggy e65852f7ac 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-30 16:12:54 +02:00
maziggy 029093ed4e 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-30 16:12:35 +02:00
maziggy 9bb597f37c fix(tests): assemble the upload batch instead of racing a sleep
The concurrent-dispatch tests went red on the Docker shard with
    "expected all 6 printers to be uploaded to concurrently, but the
    high-water mark was 4", on a scheduler that was dispatching all six
    correctly.

    peak == 6 is the claim that the sixth dispatch reaches its upload
    before the first one finishes, and the dispatches do not arrive
    together: each runs a preamble of database work first. So the
    assertion was a race between that spread and a fixed 0.15 s sleep.
    Measured here the spread is ~14 ms; on the runner it passed 150 ms.
    Padding the sleep would only move the threshold and slow every test
    that uses it.

    _UploadRecorder(assemble=True) now holds each call until every
    upload the pass launched has arrived, reading len(_inflight), which
    is filled synchronously at launch and is therefore the batch size.
    That states the property the peak assertions are about, directly and
    with no time in it, and it ends sooner than the sleep it replaces -
    the file runs 7.0s to 5.9s. _BATCH_DEADLINE_SECONDS bounds it so a
    dispatch that really has gone serial fails on its peak assertion
    instead of hanging to the pytest timeout.

    test_check_queue_returns_without_awaiting_the_uploads keeps the
    sleep, with the reason recorded next to it: it reads the rows while
    the uploads are open, and an assembled batch releases as soon as it
    is complete, which would let the dispatches flip those rows
    mid-assertion.

    The test engine also takes the application's own _set_sqlite_pragmas
    listener rather than a copy, so the file is opened the way the
    running system opens one - WAL, synchronous = NORMAL, a 15 s busy
    timeout. SQLite's defaults spend an fsync per commit and lock the
    whole file, which made a dispatch's preamble cost more than the
    upload it precedes. That is what turned the previous commit's move
    to a file-backed database into a visible failure.

    Verified in both directions rather than by a green run: with two
    printers' preambles delayed by a second, the old recorder reports
    exactly the "high-water mark was 4" the runner saw and the shared
    library test reports 3 == 4, while the assembling recorder passes
    the same scenario. Without the induced skew, ruff is clean, the full
    backend suite is green at 12224 passed, and ten consecutive runs of
    the file pinned to one core show no failures.
2026-09-30 16:11:33 +02:00
maziggy 1a80430c45 fix(tests): give each concurrent-dispatch session its own connection
The scheduler's concurrent-dispatch tests failed on CI and passed
    locally, on a commit that touched nothing but a text file. Six tests
    in tests/unit/test_scheduler_concurrent_dispatch.py went red with
    every queue item logged as "Status set to 'printing'" and four of
    them read back as "pending", alongside "cannot commit transaction -
    SQL statements in progress" and a PrintArchive that could not be
    refreshed.

    The fixtures built their farm on sqlite+aiosqlite:///:memory:, and
    SQLAlchemy backs an in-memory SQLite with a StaticPool: one DBAPI
    connection handed to every session, with nothing keeping them apart.
    That was harmless while check_queue awaited its uploads inline,
    because only one session was ever live at a time. Under the
    refillable upload pool the uploads run as concurrent background
    tasks with a session each, so their transactions interleave on that
    single connection - a sibling session's close() rolls back another's
    flushed-but-uncommitted UPDATE, and a commit() landing while another
    session still holds a cursor raises the commit error above. Whether
    the interleaving lands badly comes down to core count and
    interpreter version, which is why a 30-core box on 3.13 stayed green
    and a 4-vCPU runner on 3.11 did not.

    The four fixtures now put the database in the test's own tmp_path,
    which gets an AsyncAdaptedQueuePool and a connection per session -
    what the application itself runs with (_resolve_pool_kwargs in
    backend/app/core/database.py: pool_size 20, max_overflow 200). So
    the harness is being brought in line with production rather than
    having its assertions relaxed; nothing in the scheduler changes,
    and the concurrency under test was correct throughout.

    Checked against the failure mode rather than against a green run: an
    isolated repro of the same shape - six writer sessions and one
    reader session closing mid-transaction - yields all-pending on the
    in-memory engine and all-printing on a file. The new risk is real
    SQLite write contention on one file, so the file was run twelve
    times pinned to two cores with no failures and no lock errors.

    tests/unit/test_scheduler_busy_reasons_3018.py carries the same
    harness on an in-memory engine, but dispatches to a single printer,
    so it has no second session to race; left as is.
2026-09-30 16:11:10 +02:00
maziggy 336cb49fba Updated BACKERS 2026-09-30 16:10:47 +02:00
maziggy 5ac21d9202 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-30 16:10:24 +02:00
maziggy 463a0cf879 Housekeeping 2026-09-30 16:09:12 +02:00
maziggy b60cee2c9f Bumped version 2026-09-30 16:08:49 +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 222f28c7f8 fix(tests): assemble the upload batch instead of racing a sleep
The concurrent-dispatch tests went red on the Docker shard with
"expected all 6 printers to be uploaded to concurrently, but the
high-water mark was 4", on a scheduler that was dispatching all six
correctly.

peak == 6 is the claim that the sixth dispatch reaches its upload
before the first one finishes, and the dispatches do not arrive
together: each runs a preamble of database work first. So the
assertion was a race between that spread and a fixed 0.15 s sleep.
Measured here the spread is ~14 ms; on the runner it passed 150 ms.
Padding the sleep would only move the threshold and slow every test
that uses it.

_UploadRecorder(assemble=True) now holds each call until every
upload the pass launched has arrived, reading len(_inflight), which
is filled synchronously at launch and is therefore the batch size.
That states the property the peak assertions are about, directly and
with no time in it, and it ends sooner than the sleep it replaces -
the file runs 7.0s to 5.9s. _BATCH_DEADLINE_SECONDS bounds it so a
dispatch that really has gone serial fails on its peak assertion
instead of hanging to the pytest timeout.

test_check_queue_returns_without_awaiting_the_uploads keeps the
sleep, with the reason recorded next to it: it reads the rows while
the uploads are open, and an assembled batch releases as soon as it
is complete, which would let the dispatches flip those rows
mid-assertion.

The test engine also takes the application's own _set_sqlite_pragmas
listener rather than a copy, so the file is opened the way the
running system opens one - WAL, synchronous = NORMAL, a 15 s busy
timeout. SQLite's defaults spend an fsync per commit and lock the
whole file, which made a dispatch's preamble cost more than the
upload it precedes. That is what turned the previous commit's move
to a file-backed database into a visible failure.

Verified in both directions rather than by a green run: with two
printers' preambles delayed by a second, the old recorder reports
exactly the "high-water mark was 4" the runner saw and the shared
library test reports 3 == 4, while the assembling recorder passes
the same scenario. Without the induced skew, ruff is clean, the full
backend suite is green at 12224 passed, and ten consecutive runs of
the file pinned to one core show no failures.
2026-09-25 14:21:03 +02:00
maziggy 9f8a54234b Updated CHANGELOG 2026-09-25 14:20:46 +02:00
maziggy 523977fe97 Updated CHANGELOG 2026-09-25 13:59:43 +02:00
maziggy 330d3e7e6f fix(tests): give each concurrent-dispatch session its own connection
The scheduler's concurrent-dispatch tests failed on CI and passed
locally, on a commit that touched nothing but a text file. Six tests
in tests/unit/test_scheduler_concurrent_dispatch.py went red with
every queue item logged as "Status set to 'printing'" and four of
them read back as "pending", alongside "cannot commit transaction -
SQL statements in progress" and a PrintArchive that could not be
refreshed.

The fixtures built their farm on sqlite+aiosqlite:///:memory:, and
SQLAlchemy backs an in-memory SQLite with a StaticPool: one DBAPI
connection handed to every session, with nothing keeping them apart.
That was harmless while check_queue awaited its uploads inline,
because only one session was ever live at a time. Under the
refillable upload pool the uploads run as concurrent background
tasks with a session each, so their transactions interleave on that
single connection - a sibling session's close() rolls back another's
flushed-but-uncommitted UPDATE, and a commit() landing while another
session still holds a cursor raises the commit error above. Whether
the interleaving lands badly comes down to core count and
interpreter version, which is why a 30-core box on 3.13 stayed green
and a 4-vCPU runner on 3.11 did not.

The four fixtures now put the database in the test's own tmp_path,
which gets an AsyncAdaptedQueuePool and a connection per session -
what the application itself runs with (_resolve_pool_kwargs in
backend/app/core/database.py: pool_size 20, max_overflow 200). So
the harness is being brought in line with production rather than
having its assertions relaxed; nothing in the scheduler changes,
and the concurrency under test was correct throughout.

Checked against the failure mode rather than against a green run: an
isolated repro of the same shape - six writer sessions and one
reader session closing mid-transaction - yields all-pending on the
in-memory engine and all-printing on a file. The new risk is real
SQLite write contention on one file, so the file was run twelve
times pinned to two cores with no failures and no lock errors.

tests/unit/test_scheduler_busy_reasons_3018.py carries the same
harness on an in-memory engine, but dispatches to a single printer,
so it has no second session to race; left as is.
2026-09-25 13:59:12 +02:00
maziggy fa5f54dba3 Updated BACKERS 2026-09-25 11:46:19 +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 65b57e4141 Updated CHANGELOG 2026-09-24 15:16:34 +02:00
maziggy 9a3a93629b Housekeeping 2026-09-24 15:02:02 +02:00
maziggy ba1ff35bce Feat: add Swedish language (#3062) 2026-09-24 14:53:39 +02:00
maziggy 724ce6f6ef 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 14:53:17 +02:00
maziggy 9c0c73bd08 Updated BACKERS 2026-09-24 14:52:56 +02:00
maziggy e4ce5c56e5 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.
2026-09-24 14:52:37 +02:00
maziggy 7d75b313ed 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 14:52:17 +02:00
maziggy 7d8fb15a84 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-24 14:51:59 +02:00
maziggy 8e839d58db 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-24 14:51:33 +02:00
maziggy fb12f8d4f3 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-24 14:51:12 +02:00
maziggy 64817ecc18 Post work PR #3062 2026-09-24 13:03:09 +02:00
Anton Palmqvist 6c1006ca54 Feat: add Swedish language (#3062) 2026-09-24 12:59: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 08b4ae992f Updated BACKERS 2026-09-24 11:25:37 +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 dc4c044846 Housekeeping 2026-09-21 15:31:11 +02:00
maziggy e6c1dc43bb Updated CHANGELOG 2026-09-21 15:29:57 +02:00
maziggy bec17de946 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:28:49 +02:00
maziggy 178334fe38 . 2026-09-21 15:28:28 +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 95cbf712fc Updatd CHANGELOG 2026-09-21 15:00:29 +02:00
maziggy fd3efe4a92 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 14:54:06 +02:00
maziggy d23ba7645f Merge commit '8f9d79ccf0e6258bfd2562fb8c898ee2aa811685' into 1.2.5.6 2026-09-21 14:53:48 +02:00
maziggy 857647596a Merge pull request #2845 from pascalheidmann/refactor/modular-import
(Refactor): modularize import ("Makerworld tab")
2026-09-21 14:53:11 +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 b84b929d60 Updated CHANGELOG 2026-09-20 13:56:01 +02:00