Commit Graph
509 Commits
Author SHA1 Message Date
maziggy 14a5e33e47 Configure Orca Cloud filaments with their own filament ID (#3216)
OrcaSlicer's Sync filaments finds a slot's preset by the slot's filament
    ID alone. The Configure dialog looked up an Orca profile's ID in the
    browser and quietly sent the generic for the material when that came back
    empty, so Orca custom filaments reached the slicer as Generic and the log
    showed nothing. configure now resolves the ID on the server from
    orca_profile_id, follows inherits to the parent profile or the Bambu
    filament it was copied from, logs the outcome and reports a fallback,
    which the dialog shows as a warning.

    Slot presets also store the filament ID they were written with, so the
    slot card and the Configure dialog stop showing a preset once the slot is
    changed from OrcaSlicer's Device tab or the printer.

    Re-configuring a slot for another filament no longer carries over the old
    filament's active K-profile, which switched the slot back to the old
    filament.
2026-10-02 14:21:59 +02:00
maziggy 02e39b18b9 Configure Orca Cloud filaments with their own filament ID (#3216)
OrcaSlicer's Sync filaments finds a slot's preset by the slot's filament
ID alone. The Configure dialog looked up an Orca profile's ID in the
browser and quietly sent the generic for the material when that came back
empty, so Orca custom filaments reached the slicer as Generic and the log
showed nothing. configure now resolves the ID on the server from
orca_profile_id, follows inherits to the parent profile or the Bambu
filament it was copied from, logs the outcome and reports a fallback,
which the dialog shows as a warning.

Slot presets also store the filament ID they were written with, so the
slot card and the Configure dialog stop showing a preset once the slot is
changed from OrcaSlicer's Device tab or the printer.

Re-configuring a slot for another filament no longer carries over the old
filament's active K-profile, which switched the slot back to the old
filament.
2026-10-02 08:32:57 +02:00
maziggy 42d1083607 Show announcements from the Bambuddy maintainers
Fetch a signed feed.json from the public bambuddy-notifications repo on
    GitHub at startup and every 6 hours. Nothing about the install is sent;
    targeting (version, beta channel, install type) is decided locally.

    - Ed25519 against a key built into the app; an older serial is refused so a
      withdrawn message can't come back. The feed replaces the stored list, and
      a failed or rejected fetch keeps the last good one.
    - Sidebar entry above System with an unread count, a slide-over list, and a
      banner for unread important/critical messages. Read state per user.
    - Admins by default; Settings > General > Updates can show them to all
      users or switch them off, which also stops the fetch.
    - Plain text only; links to github.com and bambuddy.cool only.
2026-10-01 13:04:34 +02:00
maziggy 73d10ad65a Add feature: Attach camera snapshots to notifications (issue #3089) (#3199) 2026-10-01 13:01:57 +02:00
maziggy 1400e50a0d Give the two stock alerts something that fires them (issue #2955) (#3196) 2026-10-01 13:01:21 +02:00
maziggy 5eb37cd41c Show announcements from the Bambuddy maintainers
Fetch a signed feed.json from the public bambuddy-notifications repo on
GitHub at startup and every 6 hours. Nothing about the install is sent;
targeting (version, beta channel, install type) is decided locally.

- Ed25519 against a key built into the app; an older serial is refused so a
  withdrawn message can't come back. The feed replaces the stored list, and
  a failed or rejected fetch keeps the last good one.
- Sidebar entry above System with an unread count, a slide-over list, and a
  banner for unread important/critical messages. Read state per user.
- Admins by default; Settings > General > Updates can show them to all
  users or switch them off, which also stops the fetch.
- Plain text only; links to github.com and bambuddy.cool only.
2026-10-01 10:45:06 +02:00
Benji 7f64e2ba8b Add feature: Attach camera snapshots to notifications (issue #3089) (#3199) 2026-10-01 08:43:44 +02:00
Kouki Ojima 318430f0f4 Give the two stock alerts something that fires them (issue #2955) (#3196) 2026-10-01 08:28:12 +02:00
maziggy 0f32b71827 fix: display friendly legacy printer model names in stream overlay (#3134) 2026-09-30 16:24:23 +02:00
maziggy d09f9d8cc1 Sync OIDC provider groups to BamBuddy groups on every login (issue #3107) (#3122) 2026-09-30 16:23:47 +02:00
maziggy d30e0e685b Material number as a first-class spool field (#2994) 2026-09-30 16:23:17 +02:00
maziggy 80840ad6c5 Suppliers as a managed list with per-spool assignments (#2996) 2026-09-30 16:19:54 +02:00
maziggy 3b4b7439c6 (Post work): parked AMS drying timers and translated drying failures (issue #2896) 2026-09-30 16:18:06 +02:00
maziggy bc4084b2a9 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-30 16:15:43 +02:00
maziggy a6635f6783 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-30 16:14:15 +02:00
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 b60cee2c9f Bumped version 2026-09-30 16:08:49 +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
Thomansky 053cfa73ad Suppliers as a managed list with per-spool assignments (#2996) 2026-09-28 15:14:07 +02:00
maziggy b04d753186 (Post work): parked AMS drying timers and translated drying failures (issue #2896) 2026-09-28 12:35:01 +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
Thomansky 44c7e6fb39 Post-print outcome confirmation: good/reject verdicts, one-tap links, yield stats (#3047) 2026-09-26 15:37:19 +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
Thomansky 12dddada0a File Manager: external link, notes and photos on library files (#3128) 2026-09-26 08:56:48 +02:00
maziggy 7a20e731b5 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 13:40:10 +02:00
maziggy 417d03d174 fix(slicer): keep protocol-handler download tokens valid for their whole TTL (issue #3029)
The Slice and Open in Slicer actions mint a short-lived token and put it in
    the URL, because a protocol handler cannot carry an Authorization header.
    That token was spent by the first request to reach the endpoint, which made
    the handoff depend on the slicer fetching the URL exactly once. Nothing
    guarantees that: Bambu Studio's downloader retries three times after a
    failed attempt, transfers get resumed, on-access scanners fetch. The first
    request won and the slicer was handed a 403.

    verify_slicer_download_token takes a keyword-only single_use flag. The
    default still consumes via DELETE...RETURNING; single_use=False verifies
    with a SELECT and leaves the row for the rest of its five-minute TTL. The
    stored row is the same either way, so the endpoint decides, not the mint.

    The three protocol-handler downloads pass single_use=False: a library file,
    an archive's sliced 3MF, an archive's source 3MF. Resource binding and
    expiry are untouched. The two browser downloads keep consuming, because
    what they hand over is itself consumed -- the prepared printer bundle is
    deleted the moment it has been streamed.

    Also: add "/source-dl/" to PUBLIC_API_PATTERNS. Those patterns match by
    substring and the source 3MF route's segment is source-dl, which does not
    contain "/dl/", so with auth enabled the middleware rejected the slicer's
    header-less request before the route's token check ran. Open source 3MF in
    slicer could never work on an install with authentication on.
2026-09-20 13:29:25 +02:00
maziggy e1fad9d68f fix(auth): decouple media routes from the camera stream token (issue #3025)
Thirteen routes with nothing to do with a camera took the camera stream
    token as their credential -- library and archive thumbnails, plate
    previews and plate thumbnails, timelapses, print photos, archive QR
    codes, project covers, print-log thumbnails, printer covers and
    external-link icons. A browser cannot put an Authorization header on an
    <img src>, so these need a credential that fits in the URL, and the
    camera token was the only one that existed. Minting one costs
    camera:view, so a user granted library access to their own files got a
    grid of broken images until they were also handed the live camera.

    Adds a media token: minted by POST /auth/media-token behind plain
    authentication, and identified -- it records the principal the way the
    websocket token does rather than being anonymous the way the camera
    token is. Each route now gates on the permission and ownership rules of
    the resource it serves, through the same _ensure_*_visible helpers its
    header-authenticated siblings already use. The three camera routes keep
    the camera token, and require_camera_stream_token_if_auth_enabled now
    documents that it is for those only.

    The media dependencies accept ordinary Authorization / X-API-Key headers
    as well as ?token=, delegating that path to the existing checkers, so
    API-key scope rules and the per-printer allowlist are unchanged.

    Long-lived camera_stream, camwall and overlay tokens are deliberately
    not accepted on the media routes -- those are handed to kiosks, walls
    and Home Assistant to display video. The cam wall, streaming overlay and
    kiosk views use only the three camera routes and are unaffected.

    Frontend: withMediaToken alongside withStreamToken, and
    useStreamTokenSync fetches a media token for every signed-in user while
    asking for a camera token only when the user can mint one, which also
    stops the 403 that fired on every page load for everyone else.

    Also fixed, same class:
    - /printers/{id}/files/plate-thumbnail/{i} is rendered in an <img> but
      had a header-only guard, so the file manager's plate thumbnails 401'd
      whenever auth was enabled. It now takes a media token too.
    - getProjectCoverImageUrl returned a URL ending in ?token=, and the
      project edit dialog appended its own ?v= cache-buster after it, so the
      second ? landed inside the token value. The version is now a parameter
      applied before the token.

    Tests: 15 integration tests for the token boundary, permission
    enforcement and per-row scoping; 10 frontend tests for the URL split and
    the two-query hook. test_cover_image_get_uses_stream_token_gate is
    renamed and repointed at the media gate -- what it pins, that the
    credential has to fit in a URL, is unchanged.
2026-09-20 13:28:50 +02:00
maziggy 5584dca898 Bumped version 2026-09-20 13:19:02 +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 b9bd312826 fix(slicer): keep protocol-handler download tokens valid for their whole TTL (issue #3029)
The Slice and Open in Slicer actions mint a short-lived token and put it in
the URL, because a protocol handler cannot carry an Authorization header.
That token was spent by the first request to reach the endpoint, which made
the handoff depend on the slicer fetching the URL exactly once. Nothing
guarantees that: Bambu Studio's downloader retries three times after a
failed attempt, transfers get resumed, on-access scanners fetch. The first
request won and the slicer was handed a 403.

verify_slicer_download_token takes a keyword-only single_use flag. The
default still consumes via DELETE...RETURNING; single_use=False verifies
with a SELECT and leaves the row for the rest of its five-minute TTL. The
stored row is the same either way, so the endpoint decides, not the mint.

The three protocol-handler downloads pass single_use=False: a library file,
an archive's sliced 3MF, an archive's source 3MF. Resource binding and
expiry are untouched. The two browser downloads keep consuming, because
what they hand over is itself consumed -- the prepared printer bundle is
deleted the moment it has been streamed.

Also: add "/source-dl/" to PUBLIC_API_PATTERNS. Those patterns match by
substring and the source 3MF route's segment is source-dl, which does not
contain "/dl/", so with auth enabled the middleware rejected the slicer's
header-less request before the route's token check ran. Open source 3MF in
slicer could never work on an install with authentication on.
2026-09-07 14:05:25 +02:00
maziggy 816f073a9e fix(auth): decouple media routes from the camera stream token (issue #3025)
Thirteen routes with nothing to do with a camera took the camera stream
token as their credential -- library and archive thumbnails, plate
previews and plate thumbnails, timelapses, print photos, archive QR
codes, project covers, print-log thumbnails, printer covers and
external-link icons. A browser cannot put an Authorization header on an
<img src>, so these need a credential that fits in the URL, and the
camera token was the only one that existed. Minting one costs
camera:view, so a user granted library access to their own files got a
grid of broken images until they were also handed the live camera.

Adds a media token: minted by POST /auth/media-token behind plain
authentication, and identified -- it records the principal the way the
websocket token does rather than being anonymous the way the camera
token is. Each route now gates on the permission and ownership rules of
the resource it serves, through the same _ensure_*_visible helpers its
header-authenticated siblings already use. The three camera routes keep
the camera token, and require_camera_stream_token_if_auth_enabled now
documents that it is for those only.

The media dependencies accept ordinary Authorization / X-API-Key headers
as well as ?token=, delegating that path to the existing checkers, so
API-key scope rules and the per-printer allowlist are unchanged.

Long-lived camera_stream, camwall and overlay tokens are deliberately
not accepted on the media routes -- those are handed to kiosks, walls
and Home Assistant to display video. The cam wall, streaming overlay and
kiosk views use only the three camera routes and are unaffected.

Frontend: withMediaToken alongside withStreamToken, and
useStreamTokenSync fetches a media token for every signed-in user while
asking for a camera token only when the user can mint one, which also
stops the 403 that fired on every page load for everyone else.

Also fixed, same class:
- /printers/{id}/files/plate-thumbnail/{i} is rendered in an <img> but
  had a header-only guard, so the file manager's plate thumbnails 401'd
  whenever auth was enabled. It now takes a media token too.
- getProjectCoverImageUrl returned a URL ending in ?token=, and the
  project edit dialog appended its own ?v= cache-buster after it, so the
  second ? landed inside the token value. The version is now a parameter
  applied before the token.

Tests: 15 integration tests for the token boundary, permission
enforcement and per-row scoping; 10 frontend tests for the URL split and
the two-query hook. test_cover_image_get_uses_stream_token_gate is
renamed and repointed at the media gate -- what it pins, that the
credential has to fit in a URL, is unchanged.
2026-09-07 13:38:15 +02:00
maziggy 0e0bea1aa7 Bumped version 2026-08-30 08:56:26 +02:00
maziggy 0dfcff5925 Keep the RTSPS proxy's handler set off the server object (issue #3001)
asyncio's Server has a __dict__ and uvloop's, a Cython cdef class, does
not, so the attribute added in 1.2.5.4 raised AttributeError under uvloop.
Every RTSP camera failed before opening a socket, which is the
diagnostic's capture_exception at 0 ms.

Our own unit files all pin --loop asyncio for #1896 and were never
affected. The reports come from units we do not write: the Proxmox VE
Helper-Scripts LXC pins no loop, and installs predating that fix never
gained the flag because update.sh does not rewrite unit files. The loop
is not ours to assume, so fix the code rather than add another flag.

The set moves to a module-level WeakKeyDictionary, keyed weakly so an
abandoned proxy retires its own entry rather than leaking one and later
handing a new server a dead one's handlers.

Pinned on a real uvloop loop and, for hosts without uvloop, against a
__slots__ server; conftest builds its loop from the default policy, so
nothing in the suite had ever run the branch that broke.

Also routes the two external-camera teardowns through close_tls_proxy,
which #2968 introduced and left them out of.

-----

Say so at startup when running on uvloop (issue #3001)

An install on the wrong loop had no way to find out it was. #3001 was
loud enough to notice; the #1896 upload truncation it is also exposed to
is silent, and shows up as a print failing from a file that was corrupt
on arrival.

One WARNING in the lifespan naming the loop, the risk and the flag to
add. A warning and not a refusal: uvicorn has already chosen its loop by
the time any application code runs, and a server that answers requests
beats one that will not boot.

Asks the running loop what it is rather than whether uvloop imports --
uvicorn[standard] installs uvloop everywhere, so its presence says
nothing -- and matches on the module name so the question never imports
uvloop on a host without it.

-----

Repair a service file written before the --loop asyncio pin (issue #3001)

install.sh has pinned the loop since #1896, but nothing has ever
rewritten an existing service file, so every native install created
between 2025-11-28 (when uvicorn[standard] brought uvloop into the venv)
and 2026-07-05 still runs on uvloop no matter how often it is updated.

Both update scripts now add the flag themselves while the service is
stopped, so it takes effect on the same restart -- systemd via sed,
launchd via PlistBuddy, each backing the file up first and inserting
nothing but the flag.

Refuses to edit and explains instead when the shape is not a plain
single-line uvicorn unit: a wrapper script, a continued ExecStart,
several of them, a read-only file, or a service with drop-ins, since a
drop-in may be what defines ExecStart and editing the fragment would
change nothing while reporting success. A deliberate --loop uvloop is
left alone. Reads the effective ExecStart from systemd rather than the
file, so it is idempotent.
2026-08-30 08:01:42 +02:00
maziggy 3f1ed85791 Housekeeping 2026-08-29 16:57:04 +02:00
maziggy 0eb8d4b22f Mark the failure-reason migration's table name for bandit too
The line carried a noqa for ruff's S608 but nothing bandit reads, so the
same rule was silent in one tool and reported as a medium SQL-injection
finding in the other.

Nothing is interpolated but `table`, which the loop takes from a literal
tuple on the next line; the key and the label list are both bound
parameters. A table name cannot be one, which is why it is written into
the string at all.
2026-08-29 14:47:43 +02:00
maziggy 9755e08077 Decide whether a 3MF is sliced by looking inside it (issue #2993)
An archive that showed the green GCODE badge could re-import into the
    File Manager as a source-only project with no Print button, seemingly at
    random.

    Nothing was ever lost from the file. The download serves the stored
    bytes verbatim and the G-code was still in the zip; the two sides simply
    asked different questions. Archives looked inside the file. The library
    looked at the filename. So a sliced 3MF stored as Foo.3mf rather than
    Foo.gcode.3mf earned the badge and lost the Print button, and which one
    you got depended on how the print had reached the printer -- a slicer's
    LAN send names it .gcode.3mf, a per-plate export or a cloud-dispatched
    print does not.

    Both sides now ask one shared predicate about the zip itself, and every
    route into the library classifies on content. Only the central directory
    is read, and only when the name has not already settled it, so ingest
    costs nothing extra -- the external scan opens each 3MF for its
    thumbnail regardless. Rows already stored are re-checked once, internal
    ones only: an external row points at a mount that may be slow or absent,
    and startup is the worst place to discover that.

    The Slice action moves with it. Its refusal to slice an output was as
    name-bound as the Print gate, and without that a file that correctly
    gained a Print button would have offered to re-slice its own G-code.
2026-08-29 14:21:46 +02:00
maziggy 0eafe312c6 Repair the bed temperature on archives written before the fix (issue #2989)
The forward fix reads the array the fitted plate points at, but only for
    archives made after it. Everything already in the library stays blank, and
    preheat keeps falling back to the keep-warm bed temperature whenever one of
    those jobs is reprinted from the queue - 0 of 455 real 3MFs had resolved.

    A one-shot pass re-reads the 3MF already on disk, gated by a settings flag the
    way #2614's repair is: the rows it cannot fill are exactly the ones it would
    reopen every boot. It fills NULLs only. Nothing recorded is overwritten, an
    archive whose file is gone stays NULL, and a corrupted 3MF is skipped rather
    than failing startup.

    The plate mapping moves to threemf_tools.bed_temperature_from_config so the
    ingest path and the repair cannot read a 3MF differently - the same drift
    move.

    _extract_settings_from_content is deleted. Nothing called it anywhere in the
    repo, and it carried the old bed_temperature mapping this issue fixed.
2026-08-29 14:19:49 +02:00
maziggy 2e405afcd1 Decide whether a 3MF is sliced by looking inside it (issue #2993)
An archive that showed the green GCODE badge could re-import into the
File Manager as a source-only project with no Print button, seemingly at
random.

Nothing was ever lost from the file. The download serves the stored
bytes verbatim and the G-code was still in the zip; the two sides simply
asked different questions. Archives looked inside the file. The library
looked at the filename. So a sliced 3MF stored as Foo.3mf rather than
Foo.gcode.3mf earned the badge and lost the Print button, and which one
you got depended on how the print had reached the printer -- a slicer's
LAN send names it .gcode.3mf, a per-plate export or a cloud-dispatched
print does not.

Both sides now ask one shared predicate about the zip itself, and every
route into the library classifies on content. Only the central directory
is read, and only when the name has not already settled it, so ingest
costs nothing extra -- the external scan opens each 3MF for its
thumbnail regardless. Rows already stored are re-checked once, internal
ones only: an external row points at a mount that may be slow or absent,
and startup is the worst place to discover that.

The Slice action moves with it. Its refusal to slice an output was as
name-bound as the Print gate, and without that a file that correctly
gained a Print button would have offered to re-slice its own G-code.
2026-08-29 14:14:26 +02:00
maziggy b0ecb8fd88 Repair the bed temperature on archives written before the fix (issue #2989)
The forward fix reads the array the fitted plate points at, but only for
archives made after it. Everything already in the library stays blank, and
preheat keeps falling back to the keep-warm bed temperature whenever one of
those jobs is reprinted from the queue - 0 of 455 real 3MFs had resolved.

A one-shot pass re-reads the 3MF already on disk, gated by a settings flag the
way #2614's repair is: the rows it cannot fill are exactly the ones it would
reopen every boot. It fills NULLs only. Nothing recorded is overwritten, an
archive whose file is gone stays NULL, and a corrupted 3MF is skipped rather
than failing startup.

The plate mapping moves to threemf_tools.bed_temperature_from_config so the
ingest path and the repair cannot read a 3MF differently - the same drift
move.

_extract_settings_from_content is deleted. Nothing called it anywhere in the
repo, and it carried the old bed_temperature mapping this issue fixed.
2026-08-29 09:23:48 +02:00
MartinNYHC 659d77c205 Store a failure reason in one vocabulary, not three (issue #2974)
failure_reason was written three different ways and nothing reconciled
    them. derive_failure_reason wrote English display labels ("Layer shift"),
    older builds of the archive editor wrote the translated label in whatever
    locale that user was running, and the two stale-archive paths wrote
    English prose sentences. All three reach one column -- the archive PATCH
    has mirrored the field onto the latest print-log entry since #1444 -- and
    the Failure Analysis widget groups on the raw value, so one real cause
    occupied several buckets. On a live install before this landed:
    print_log_entries held 91 rows reading "User cancelled" beside 1 reading
    "userCancelled".

    In an English UI those two render as the same words twice with different
    counts, which is why nobody spotted it. In any other locale one of them
    stays English, because a stored label has no key for t() to resolve. The
    editor was worse than cosmetic about it: its reverse lookup compared the
    stored value against t() in the current locale, so for a non-English user
    nothing matched and the dropdown opened empty over an archive that
    plainly showed a reason.

    The keys were already canonical and already enforced.
    _FAILURE_REASON_KEYS in api/routes/print_log.py rejects anything else
    with a 400 and explains why in its own comment -- the widget renders
    values back through t(), so an unrecognised one surfaces as a raw string.
    derive_failure_reason had simply never been held to that rule. It now
    produces keys, and the cancel branch returns userCancelled.

    The two "Stale - ..." sentences become one new noStatusUpdate key. Both
    describe the same observation, that no end-of-print status ever arrived;
    which of the two situations occurred is already carried by status --
    cancelled at the stale-cleanup site, the reconciled outcome at the
    reconnect site -- so collapsing them loses nothing and gives Statistics
    one bucket instead of two sentences that could never be translated. It
    had to enter the vocabulary rather than merely be tolerated, because the
    editor discards any value it does not recognise.

    Existing rows are converted by a startup migration folding 168 historical
    labels onto the 12 keys across both columns. It is exact rather than a
    guess: every label across all 14 locales resolves to exactly one key,
    with no collisions. The map is a frozen snapshot rather than something
    read from the locale files at run time -- it maps what was written
    historically, so regenerating it from the current translations would
    silently stop recognising the very rows it exists to convert. A value
    outside the map is left alone; guessing would be worse than leaving one
    honest string in its own bucket. There is no one-shot settings flag, on
    purpose: the statement only matches values in the map and a key is never
    a label, so it is self-terminating, and a flag would permanently skip
    anyone who restores an older database.

    The last part is a data-loss bug that was not in the report. The editor's
    fallback to '' was not merely a wrong-looking dropdown -- the empty
    selection was then saved over the stored text, so opening the editor on
    an archive whose reason was free text and pressing Save destroyed the
    classification. An unrecognised value now keeps its own option and
    survives a save.
2026-08-28 12:41:46 +02:00
MartinNYHC 4705a3027a Configure a spool's filament preset and K profile per nozzle
A slicer preset is bound to a printer model: "Bambu PLA Basic @BBL X1C" is
    not the same preset as "@BBL H2C", and Bambu names a nozzle size in it as
    well. A spool carried exactly one, which was right until the same spool was
    used on a second machine -- the AMS slot on the other one was then
    configured with a preset that machine has no profile for. K profiles had
    the matching gap from the other side: the tables have always been keyed per
    hotend, but the picker could not express it.

    spool_filament_preset and its Spoolman twin store the exceptions, keyed
    (spool, printer_model, nozzle_diameter). Model rather than printer because
    the preset is a property of the model -- "@BBL X1C" is the same preset on
    every X1C, and asking per machine would mean picking the identical value
    twice. K profiles stay on printer_id, because a K value is measured on one
    physical hotend and two machines of the same model legitimately differ.
    Resolution is exact (model, diameter) -> (model, "") -> the spool's own
    preset, so a spool nobody has configured behaves exactly as it did before.
    The form writes one row per nozzle size and never the "" row; that level is
    kept for API clients wanting one value to cover a model.

    Both halves cover every standard nozzle size rather than the size currently
    fitted, because a spool is configured once and nozzles get swapped. The PA
    Profile tab becomes a Printers tab: a model list beside a detail pane
    holding a preset row per size and a K-profile grid of size by hotend. Each
    model is offered only the presets that name it, through the same matcher
    the Configure AMS Slot modal filters with, which moves out of that
    component into utils/slicerPrinterMatch. Presets whose name identifies no
    model -- most user-authored and OrcaSlicer ones -- stay offered everywhere,
    as does whatever is already selected, so a saved override cannot vanish
    from the control that shows it. Every preset carries an origin badge in the
    wording and colours that modal already uses.

    Every path that configures a slot now respects both: manual assign in
    either inventory mode, RFID auto-assign, the Spoolman tag link, the re-fire
    when a slot goes empty to loaded, the re-apply after a calibration-table
    refresh, and the re-selection when a Filament Track Switch moves an AMS to
    the other nozzle. Which nozzle a slot feeds, and how wide it is, was worked
    out independently in seven of those places, each reading nozzles[0] for
    every slot on the machine -- correct on a single-nozzle printer and on a
    dual-nozzle printer with matching nozzles, wrong the moment two sizes are
    fitted. That resolution is now services/slot_nozzle.

    Which array entry belongs to which hotend is no longer inferred. Measured
    on an H2D fitted with a 0.4 high flow on the left and a 0.6 on the right,
    nozzles[0] reads the right hotend, so the array is indexed by extruder id
    and the H2/X2 parser's convention is the one that holds. The legacy
    parser's opposite convention never governs a real dual-nozzle machine:
    every model in DUAL_NOZZLE_MODELS reports device.nozzle.info, and
    left_nozzle_diameter appears in no log or wire capture. Two comments that
    said otherwise were wrong and are fixed; amsHelpers' code was right all
    along and only its comment lied.

    Four defects surfaced while wiring it, all pre-existing except the last.
    The picker identified a chosen calibration by cali_idx alone, and the
    printer numbers its calibration table per nozzle -- on a dual-nozzle
    machine the same index exists on both hotends meaning different things, so
    saving could persist the other hotend's K value and diameter; SpoolBuddy's
    write-tag page carried a verbatim copy and gets the same fix. RFID
    auto-assign chose a K profile with no extruder test at all, so a spool
    calibrated on both hotends had a coin toss decide which pressure-advance
    value the slot got, on the path that runs unattended every time a Bambu
    spool is loaded. The Spoolman tag-link path resolved no preset whatsoever,
    configuring every linked slot with a generic material id and discarding a
    preset set in inventory -- the same defect #1713 fixed on the assign path,
    one function over. And an FTS inlet move re-selected K for nozzle 0 rather
    than for the nozzle the AMS had just been moved to.

    The last one is new here: a per-model override can be a cloud USER preset,
    whose PFUS-prefixed id the slicer rejects, and passing it straight into
    extrusion_cali_sel would silently lose the K-profile link. Reached the
    printer only where such an override exists, which is why nothing in the
    suite caught it. printer_safe_filament_id falls through to the spool's own
    preset and then the tray's RFID value instead.

    Reading a printer's calibration table asks for one nozzle size at a time.
    H2-series firmware answers only the first one or two of a concurrent burst
    of extrusion_cali_get and silently drops the rest, each dropped request
    costing a five-second timeout before its retry: measured at 11 and 23
    seconds on an H2C and an H2D for four parallel requests, against roughly
    one second in series. An X1C answers all four at once, which is why this
    only ever surfaced on dual-diameter printers. Printers themselves are read
    in parallel -- separate machines are separate connections.

    The Configure AMS Slot dialog opens on the spool's own configured values,
    falling back to the slot's last manual configuration and then the tray's
    RFID data. The spool form is wider for the two-pane layout, colour, weight,
    cost and location move to their own tab in two columns, and a printer card
    in expanded view lists every fitted nozzle size rather than the first entry
    alone.
2026-08-28 12:30:51 +02:00
maziggy 5211fd4575 Store a failure reason in one vocabulary, not three (issue #2974)
failure_reason was written three different ways and nothing reconciled
them. derive_failure_reason wrote English display labels ("Layer shift"),
older builds of the archive editor wrote the translated label in whatever
locale that user was running, and the two stale-archive paths wrote
English prose sentences. All three reach one column -- the archive PATCH
has mirrored the field onto the latest print-log entry since #1444 -- and
the Failure Analysis widget groups on the raw value, so one real cause
occupied several buckets. On a live install before this landed:
print_log_entries held 91 rows reading "User cancelled" beside 1 reading
"userCancelled".

In an English UI those two render as the same words twice with different
counts, which is why nobody spotted it. In any other locale one of them
stays English, because a stored label has no key for t() to resolve. The
editor was worse than cosmetic about it: its reverse lookup compared the
stored value against t() in the current locale, so for a non-English user
nothing matched and the dropdown opened empty over an archive that
plainly showed a reason.

The keys were already canonical and already enforced.
_FAILURE_REASON_KEYS in api/routes/print_log.py rejects anything else
with a 400 and explains why in its own comment -- the widget renders
values back through t(), so an unrecognised one surfaces as a raw string.
derive_failure_reason had simply never been held to that rule. It now
produces keys, and the cancel branch returns userCancelled.

The two "Stale - ..." sentences become one new noStatusUpdate key. Both
describe the same observation, that no end-of-print status ever arrived;
which of the two situations occurred is already carried by status --
cancelled at the stale-cleanup site, the reconciled outcome at the
reconnect site -- so collapsing them loses nothing and gives Statistics
one bucket instead of two sentences that could never be translated. It
had to enter the vocabulary rather than merely be tolerated, because the
editor discards any value it does not recognise.

Existing rows are converted by a startup migration folding 168 historical
labels onto the 12 keys across both columns. It is exact rather than a
guess: every label across all 14 locales resolves to exactly one key,
with no collisions. The map is a frozen snapshot rather than something
read from the locale files at run time -- it maps what was written
historically, so regenerating it from the current translations would
silently stop recognising the very rows it exists to convert. A value
outside the map is left alone; guessing would be worse than leaving one
honest string in its own bucket. There is no one-shot settings flag, on
purpose: the statement only matches values in the map and a key is never
a label, so it is self-terminating, and a flag would permanently skip
anyone who restores an older database.

The last part is a data-loss bug that was not in the report. The editor's
fallback to '' was not merely a wrong-looking dropdown -- the empty
selection was then saved over the stored text, so opening the editor on
an archive whose reason was free text and pressing Save destroyed the
classification. An unrecognised value now keeps its own option and
survives a save.
2026-08-28 08:28:27 +02:00
maziggy a7b563334e Configure a spool's filament preset and K profile per nozzle
A slicer preset is bound to a printer model: "Bambu PLA Basic @BBL X1C" is
not the same preset as "@BBL H2C", and Bambu names a nozzle size in it as
well. A spool carried exactly one, which was right until the same spool was
used on a second machine -- the AMS slot on the other one was then
configured with a preset that machine has no profile for. K profiles had
the matching gap from the other side: the tables have always been keyed per
hotend, but the picker could not express it.

spool_filament_preset and its Spoolman twin store the exceptions, keyed
(spool, printer_model, nozzle_diameter). Model rather than printer because
the preset is a property of the model -- "@BBL X1C" is the same preset on
every X1C, and asking per machine would mean picking the identical value
twice. K profiles stay on printer_id, because a K value is measured on one
physical hotend and two machines of the same model legitimately differ.
Resolution is exact (model, diameter) -> (model, "") -> the spool's own
preset, so a spool nobody has configured behaves exactly as it did before.
The form writes one row per nozzle size and never the "" row; that level is
kept for API clients wanting one value to cover a model.

Both halves cover every standard nozzle size rather than the size currently
fitted, because a spool is configured once and nozzles get swapped. The PA
Profile tab becomes a Printers tab: a model list beside a detail pane
holding a preset row per size and a K-profile grid of size by hotend. Each
model is offered only the presets that name it, through the same matcher
the Configure AMS Slot modal filters with, which moves out of that
component into utils/slicerPrinterMatch. Presets whose name identifies no
model -- most user-authored and OrcaSlicer ones -- stay offered everywhere,
as does whatever is already selected, so a saved override cannot vanish
from the control that shows it. Every preset carries an origin badge in the
wording and colours that modal already uses.

Every path that configures a slot now respects both: manual assign in
either inventory mode, RFID auto-assign, the Spoolman tag link, the re-fire
when a slot goes empty to loaded, the re-apply after a calibration-table
refresh, and the re-selection when a Filament Track Switch moves an AMS to
the other nozzle. Which nozzle a slot feeds, and how wide it is, was worked
out independently in seven of those places, each reading nozzles[0] for
every slot on the machine -- correct on a single-nozzle printer and on a
dual-nozzle printer with matching nozzles, wrong the moment two sizes are
fitted. That resolution is now services/slot_nozzle.

Which array entry belongs to which hotend is no longer inferred. Measured
on an H2D fitted with a 0.4 high flow on the left and a 0.6 on the right,
nozzles[0] reads the right hotend, so the array is indexed by extruder id
and the H2/X2 parser's convention is the one that holds. The legacy
parser's opposite convention never governs a real dual-nozzle machine:
every model in DUAL_NOZZLE_MODELS reports device.nozzle.info, and
left_nozzle_diameter appears in no log or wire capture. Two comments that
said otherwise were wrong and are fixed; amsHelpers' code was right all
along and only its comment lied.

Four defects surfaced while wiring it, all pre-existing except the last.
The picker identified a chosen calibration by cali_idx alone, and the
printer numbers its calibration table per nozzle -- on a dual-nozzle
machine the same index exists on both hotends meaning different things, so
saving could persist the other hotend's K value and diameter; SpoolBuddy's
write-tag page carried a verbatim copy and gets the same fix. RFID
auto-assign chose a K profile with no extruder test at all, so a spool
calibrated on both hotends had a coin toss decide which pressure-advance
value the slot got, on the path that runs unattended every time a Bambu
spool is loaded. The Spoolman tag-link path resolved no preset whatsoever,
configuring every linked slot with a generic material id and discarding a
preset set in inventory -- the same defect #1713 fixed on the assign path,
one function over. And an FTS inlet move re-selected K for nozzle 0 rather
than for the nozzle the AMS had just been moved to.

The last one is new here: a per-model override can be a cloud USER preset,
whose PFUS-prefixed id the slicer rejects, and passing it straight into
extrusion_cali_sel would silently lose the K-profile link. Reached the
printer only where such an override exists, which is why nothing in the
suite caught it. printer_safe_filament_id falls through to the spool's own
preset and then the tray's RFID value instead.

Reading a printer's calibration table asks for one nozzle size at a time.
H2-series firmware answers only the first one or two of a concurrent burst
of extrusion_cali_get and silently drops the rest, each dropped request
costing a five-second timeout before its retry: measured at 11 and 23
seconds on an H2C and an H2D for four parallel requests, against roughly
one second in series. An X1C answers all four at once, which is why this
only ever surfaced on dual-diameter printers. Printers themselves are read
in parallel -- separate machines are separate connections.

The Configure AMS Slot dialog opens on the spool's own configured values,
falling back to the slot's last manual configuration and then the tray's
RFID data. The spool form is wider for the two-pane layout, colour, weight,
cost and location move to their own tab in two columns, and a printer card
in expanded view lists every fitted nozzle size rather than the first entry
alone.
2026-08-27 13:03:15 +02:00
maziggy 8f182dc181 Read a NULL notification flag as off instead of dropping every provider (issue #2827)
Adding on_stock_reorder_alert and on_stock_break_alert to the provider
    schema made them required on the way out as well as in: the response model
    inherits the write model. Every on_* column on notification_providers is
    nullable with no server default, and where the table was created from
    Base.metadata before run_migrations, the ALTER ... DEFAULT false that
    introduced those columns was swallowed as a duplicate and never backfilled
    existing rows. Those NULLs were harmless until the flags were read, at
    which point the row failed validation -- and a list is validated as a
    whole, so one row took every provider with it. The route returned 500 and
    the UI rendered an empty list, so configured providers looked deleted.

    Backfill them to off, which is what the sender already assumed: it selects
    providers with IS TRUE, so a NULL flag never sent anything. A NULL flag now
    also reads as off rather than failing the response, across all of them, so
    the next flag added to this schema cannot repeat it. Writes are unchanged.
2026-08-26 10:19:17 +02:00
maziggy bb82fbc337 Decide migration idempotency by SQLSTATE, not by English error text (issue #2949)
PostgreSQL renders its messages in the server's lc_messages locale. _safe_execute
    recognised an already-applied statement by searching the error text for
    "already exists", so a Russian-locale server -- which says "уже существует" --
    re-raised it and aborted startup.

    The column already existing is the expected outcome: create_all() builds the
    tables from the models before the migration list runs, so on a fresh database
    essentially every ADD COLUMN in that list is a duplicate by design, and all 382
    of them relied on that recognition. No PostgreSQL server outside an English
    locale could start Bambuddy at all, fresh install or upgrade.

    Classify on SQLSTATE instead -- 42701, 42P07, 42710, 23505 -- which PostgreSQL
    never translates. The existing narrowing is kept and now rests on a code rather
    than a phrase: a missing column counts as already-applied only for RENAME COLUMN,
    so a missing column during ADD COLUMN or CREATE INDEX still aborts rather than
    hiding a corrupt schema. SQLite keeps the text match; its driver publishes no
    SQLSTATE and it does not localise. The OIDC auto-link constraint read message
    text the same way and gets the same treatment.

    Verified against PostgreSQL 15 under ru_RU, en_US and C: init_db() completes on
    a fresh database and on a re-run in all three, and the schema the Russian server
    ends up with is byte-identical to the English one.
2026-08-26 10:17:03 +02:00