485 Commits
Author SHA1 Message Date
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 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 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
maziggy 5be2151f99 Take an RFID spool's core weight from the row that names it (issue #2909) (#2923) 2026-08-26 10:16:15 +02:00
maziggy 5de06cca0f Stop offering AMS slots as places to store a spool
The Storage Location dropdown listed entries like "H2D-1 - AMS A1" next to
        real locations, and they could not be got rid of.

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

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

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

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

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

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

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

        ------

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

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

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

        Rows are identified by the signature of the broken lookup: added by RFID,
        carrying the weight of one of the other Bambu catalogue rows, with the
        weights read out of the catalogue rather than hardcoded so an install
        whose rows have been re-measured is repaired to its own numbers. Keying on
        whether a catalogue row had been recorded would not have worked -- the
        weight picker auto-selects the only row matching the weight and writes its
        id on the next save, so that column says only whether the form was ever
        opened. The one case that cannot be told apart is stated rather than
        hidden: someone who moved an RFID roll onto a genuine High Temp spool and
        set 216 g by hand is normalised with the rest. Runs exactly once, so a
        tare set afterwards is kept.
2026-08-26 10:13:42 +02:00
maziggy d9bc7ae47a 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-25 13:09:24 +02:00
MagicMelody84 54af3146a3 [Feature]: Bind Home Assistant sensors to storage locations (dryboxes/bins) (#2827) 2026-08-25 12:17:23 +02:00
maziggy ea898141f0 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-25 07:55:49 +02:00
Kouki Ojima f95c81e6cd Take an RFID spool's core weight from the row that names it (issue #2909) (#2923) 2026-08-24 11:51:22 +02:00
maziggy 537b4d2509 Stop offering AMS slots as places to store a spool
The Storage Location dropdown listed entries like "H2D-1 - AMS A1" next to
    real locations, and they could not be got rid of.

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

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

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

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

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

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

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

    ------

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

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

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

    Rows are identified by the signature of the broken lookup: added by RFID,
    carrying the weight of one of the other Bambu catalogue rows, with the
    weights read out of the catalogue rather than hardcoded so an install
    whose rows have been re-measured is repaired to its own numbers. Keying on
    whether a catalogue row had been recorded would not have worked -- the
    weight picker auto-selects the only row matching the weight and writes its
    id on the next save, so that column says only whether the form was ever
    opened. The one case that cannot be told apart is stated rather than
    hidden: someone who moved an RFID roll onto a genuine High Temp spool and
    set 216 g by hand is normalised with the rest. Runs exactly once, so a
    tare set afterwards is kept.
2026-08-23 14:25:45 +02:00
maziggy 89f3b1cc58 Add printer video downloads and range selection (#2853) 2026-08-23 12:45:39 +02:00
Sean K 55cc64c87d Add printer video downloads and range selection (#2853) 2026-08-22 13:22:56 +02:00
maziggy 600bf8e24e Bumped version && updated CHANGELOG 2026-08-17 15:59:25 +02:00
maziggy f855d8dcca Pin the PostgreSQL session to UTC so defaulted timestamps are UTC (#2855)
On a UTC+3 install every AMS humidity reading and every archive was
    stamped three hours ahead of when it happened. Bambuddy stores naive
    timestamps that hold UTC and the frontend's parseUTCDate reads an
    offsetless timestamp as UTC, so the display added the offset to a value
    that was already local.

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

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

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

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

    One related mismatch goes with it, because fixing the database side alone
    would have made it start lying on exactly the installs this repairs. The
    support package's oldest_pending_age_seconds subtracted a naive local
    clock from a naive UTC column, with a comment claiming it was UTC; on the
    reporter's install the two errors cancelled. It reported a job queued
    five minutes ago as three hours old east of Greenwich and a negative age
    west of it. The two AMS and printer-sensor retention cutoffs move to the
    same utcnow_naive helper -- correct in value already, but deprecated in
    3.12 and emitting warnings on every sweep.
2026-08-17 15:46:44 +02:00
maziggy 64a1defa7a Stop the AMS temperature alert firing for heat the user asked for (#1802)
The alert compares against ams_temp_fair, the same threshold that colours
    the printer card, which defaults to 35C. Drying deliberately runs at 45C
    for PLA, 65C for PETG and up to 85C on an AMS-HT, and the alert repeats
    once an hour for as long as the condition holds, so a twelve-hour dry
    sent twelve notifications about a temperature the user chose. It then
    kept sending them while the unit cooled back down, which is the half the
    reporter confirmed on an AMS 2 Pro and an H2C.

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

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

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

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

    No new setting. The reporter was offered the opt-out checkbox they asked
    for and said they would not want it if the alert simply never fired
    during drying.
2026-08-17 15:43:42 +02:00
maziggy 28b2b9f151 Pin the PostgreSQL session to UTC so defaulted timestamps are UTC (#2855)
On a UTC+3 install every AMS humidity reading and every archive was
stamped three hours ahead of when it happened. Bambuddy stores naive
timestamps that hold UTC and the frontend's parseUTCDate reads an
offsetless timestamp as UTC, so the display added the offset to a value
that was already local.

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

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

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

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

One related mismatch goes with it, because fixing the database side alone
would have made it start lying on exactly the installs this repairs. The
support package's oldest_pending_age_seconds subtracted a naive local
clock from a naive UTC column, with a comment claiming it was UTC; on the
reporter's install the two errors cancelled. It reported a job queued
five minutes ago as three hours old east of Greenwich and a negative age
west of it. The two AMS and printer-sensor retention cutoffs move to the
same utcnow_naive helper -- correct in value already, but deprecated in
3.12 and emitting warnings on every sweep.
2026-08-17 08:05:02 +02:00
maziggy 95e28e39ce Bumped version 2026-08-15 15:07:17 +02:00
maziggy 2d324dc7d0 Let API keys read and run slicer pipelines (#1425 follow-up)
Every pipeline endpoint answered 403 for API keys whatever scopes the key
    carried. PR A parked all three permissions on the admin denylist until the
    run dispatch existed to decide about; it landed in PR C and the parking was
    never revisited.

    PIPELINES_READ now rides can_read_status. PIPELINES_RUN requires
    can_queue AND can_manage_library together, so the allowlist gained tuple
    values: a run slices into the library and then queues prints, and mapping
    it to either flag alone would hand that flag the other one's authority.
    The 403 names every flag the key is short of. PIPELINES_WRITE stays
    admin-only -- a key can run the recipe, not rewrite it or clear the log.

    Opening the run route also needed the cloud-owner fallback the direct
    slice route makes: a pipeline can carry Bambu/Orca Cloud presets, and
    resolving those reads a token off a user record that an API-keyed request
    does not have. retry_failed forwards the new dependency explicitly,
    since a direct call receives the Depends marker rather than None.
2026-08-15 15:02:53 +02:00
maziggy dde724f83c Repair no-3MF archives' photos and their silent filament writes (#1820)
Two faults behind the same kind of print: one that arrives without a
    retrievable 3MF, which on an H2S is any job started from the printer's
    own internal library.

    Such an archive has no file_path, and Path("").parent is Path("."), so
    every site that derived the archive's folder from it landed on the data
    directory itself. The finish-photo capture spotted that and wrote to
    <archive_dir>/<id>/photos instead. Nothing else did. The photo was
    written in one place and looked for in another: reads 404'd, deletes
    dropped the name and left the file, and the notification attachment
    never found the image. Hand-uploaded photos worked only because upload
    and read agreed with each other rather than with the capture. Give the
    question one owner in utils/archive_paths and have all four sites ask
    it. Lookups check the old shared location too, so photos already
    uploaded there stay reachable; uploads now go where captures go.

    Separately, the remain%-delta fallback that stands in for a missing 3MF
    can charge nothing for several reasons, and did so without a word. The
    AMS reading is coarse and, on the reporter's printer, noisy: it rises
    mid-print, swings five points over a job, sits at 100% through a
    36-minute print on a fresh spool, and goes negative on a nearly empty
    one -- which the start-of-print gate rejects, dropping the only slot
    that was printing. Two of their prints went uncounted for two different
    reasons and both read as "no spools updated", which is also what a print
    with nothing to charge prints. Name the slot and the two readings in
    each case, on the Spoolman path and on the internal-inventory path,
    which has carried the same gates since #1119.

    The Spoolman path also had no notion of which slots the print used, so a
    spool swapped into an idle slot mid-print reads as consumption and is
    billed to whoever that slot is assigned to -- the fault #1269 fixed for
    the internal tracker, still open here, and likeliest on exactly the
    prints this fallback serves, where nothing else narrows the field. Use
    the same three pieces of evidence it does: the print's mapping, its
    mid-print tray changes, and the tray it started on. The last needs
    storing, because the internal tracker's row is deleted before this runs
    and a screen-started print has no mapping to fall back on -- hence a new
    nullable column, and no backfill, since a row from before it existed has
    nothing to say. Where no evidence exists at all, every slot is still
    considered.

    Both paths also treated tray_now == 255 as naming a slot. It does not:
    it is the field's initial value, the fallback for an unparseable
    reading, and what it reports with nothing loaded. Mapped as a tray id it
    becomes (255, 1), so as the only evidence it excluded every real slot
    and charged nothing at all -- this issue's own bug, arriving by a new
    route. On the internal path that is live today; on the Spoolman path it
    would have shipped with the guard above. The external holder reports 254
    when it is genuinely in use.

    The arithmetic is untouched: at one percent per step this cannot resolve
    a small print, and pretending otherwise would be worse than saying so.
2026-08-15 15:01:42 +02:00
maziggy 8e553289db Choose which rack nozzle each filament prints from on an H2C (#1784)
The Vortek rack holds six hotends, and a multi-colour plate is sliced to
    use a different one per colour so it can skip the purge. Which of the six
    each colour takes is not in the 3MF. The same plate, sliced and sent twice
    from Bambu Studio with a different choice each time, produces two files
    that differ only in rounding in the last digit of a few extrusion figures
    -- the filament grouping, the toolchange stream, the 120 nozzle-change
    markers and project_settings.config are all identical. The choice travels
    only in the dispatched nozzle_mapping.

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

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

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

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

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

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

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

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

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

    Tests: 44 unit covering the plan, the resolver, the mounted-nozzle
    recovery and every refusal; 9 dispatch integration asserting the two real
    captures end to end; 7 API round-trip; 33 frontend. The API ones exist
    because two integration bugs got through a green suite that tested the
    pieces and not the seams -- the group data reached only one of the three
    filament-requirements routes, and the field was declared on every schema
    except the create one, where Pydantic dropped it in silence.
2026-08-15 14:59:41 +02:00
maziggy 3ac289fccb Attribute filament correctly when AMS backup swaps spools mid-print
Everything the completion path needs to split a print's filament across
    the trays it fed from lived only in memory: the dispatched plate and
    slot-to-tray mapping, the spool-assignment snapshot, and the tray-change
    log. A print that outlived a restart lost all of it and fell back to
    what the printer reports at completion -- which, with AMS Filament
    Backup on, is the substitute tray. The whole print was charged to the
    spool that only finished it while the spool that ran dry was charged
    nothing.

    Persist that context in a new active_print_sessions row, append tray
    changes as they happen, and restore both the session and the printer's
    tray-change log at restart recovery. Seed the log from the current tray
    when there is nothing to restore, since last_loaded_tray advances even
    when no change is logged.

    Rank the queue item's stored ams_mapping above the printer's live
    mapping field, which is what backup rewrites. Recover plate_id from the
    archive or queue item, and give extract_layer_filament_usage_from_3mf a
    plate_id instead of taking the first .gcode member -- a Bambu Studio
    export stores plate 2 first, so per-layer figures were measured against
    the wrong plate for both inventory backends.

    Stop auto-unlinking a spool assignment when its slot reports empty
    during a running print. At a runout the spool is still in the AMS, and
    dropping the link leaves the completion path nothing to charge.

    Capture the print-start context for both inventory backends. Spoolman's
    own durable row (#1820) carries its plate-scoped figures and dispatched
    mapping but not the tray-change log, and its slot assignments -- the
    way. Registration in _active_sessions stays gated, since on_ams_change
    reads it to decide whether to skip the remain%-based weight sync (#880).
2026-08-15 14:56:04 +02:00
maziggy 0a0c31cca0 Give the variant-group backfill query the nosec marker that applies
The line carried "# noqa: S608", which is ruff's flake8-bandit code -- but S is
    not in ruff's select list in pyproject.toml, so ruff never ran that rule and the
    marker suppressed nothing. Bandit itself only honours "# nosec", so the query
    went on being reported as B608 while the line read as already handled.

    The finding is a false positive. The only interpolated fragments are source_expr
    and model_expr, assigned just above from a two-branch is_sqlite() check where
    both branches are string literals; no caller value reaches the string. They are
    JSON expressions rather than values, so a bind parameter cannot express them.

    Replaces the inert marker with "# nosec B608", matching the convention already
    used across the test suite, and moves the reasoning into a comment above the
    statement. Bandit's medium+ count drops to 16, none of them B608.
2026-08-15 14:52:25 +02:00
maziggy 42946df211 Let API clients resolve user ids to names (#1894)
Archives, the queue and statistics report ownership as a numeric
    created_by_id, and statistics accept it as a filter, but nothing let an
    API key discover whose id was whose -- the only user listing returns
    emails, roles, group membership and full permission sets, so it is
    administrative and rejects keys.

    Add GET /users/slim returning id + username only, gated on a new
    users:read_slim permission mapped to can_read_status. That grants no
    data a key could not already reach: for API-keyed requests the
    permission deps return None as current_user, so the stats:filter_by_user
    guard short-circuits and ?created_by_id=N is already honoured for every
    N. What was missing was the ability to address the filter, not
    permission to use it. The full listing stays unmapped = admin-only.

    Also fix /auth/me, which answered an API key with a synthetic
    administrator: id 0, role admin, is_admin true and every permission in
    the enum. A key cannot reach an administrative route at all, so clients
    building their UI from that response rendered actions that 403 on use.
    It now reports the key owner's identity, is_admin false, and the
    permissions the key's scopes actually admit. Ownerless legacy keys keep
    id 0 but no longer claim admin.

    ---

    Source user names from the slim listing where only names are needed (#1894)

    Stats filter-by-user, the Archives print log filter, the File Manager
    username autocomplete, the camera-token owner column and the Finance
    member picker all render nothing but a username, but all of them read
    the full user listing, which is gated on the admin-level users:read.
    An operator granted stats:filter_by_user but not users:read got an
    empty filter with no indication why.

    Point them at /users/slim under a separate react-query key, since the
    full listing shares the 'users' key and the two shapes would clobber
    each other in the cache.
2026-08-15 14:50:49 +02:00
maziggy 7d137e312b Stop auto-drying re-arming into a threshold it can never reach (#2770)
An H2D armed five 12-hour drying cycles inside four hours, one of them six
    seconds after the previous one ended, and none ran more than a couple of
    hours.

    Two things combine. The firmware ends a cycle when it decides the filament
    is dry rather than when the clock runs out, and reports no fault doing it --
    across this printer's history the run length tracks how wet the spools were,
    from nearly the full 12 hours starting at 32% down to minutes once the unit
    sat at 10-13%. That part is the AMS doing its job.

    The loop is ours. An AMS reports higher relative humidity while it is warm
    than once it has cooled: the same unit read 10-13% cold and 15-20% through
    every cycle. With the threshold at 14% the reading at the moment a cycle
    ended was always still above it, so the next 30-second pass armed another
    12-hour cycle. Nothing counted, nothing waited, and it only stopped when the
    box finally cooled enough to read 13%.

    Auto-drying now waits 30 minutes after a cycle ends before arming another on
    the same unit, and gives up on a unit after two consecutive cycles that
    bring the reading no lower -- logging why and sending a new notification,
    on by default because it reports that Bambuddy has stopped acting. Progress
    is judged against the lowest reading any cycle on that unit has ended at,
    not against the threshold, so a genuinely wet spool in a humid room coming
    down 40-37-35 keeps drying however far it still is from the target;
    comparing against the best so far rather than the previous end stops a
    sensor wobbling by one point reading as progress every other cycle. The
    suspension lifts by itself once the reading falls below the threshold.

    Neither guard can stop a running cycle, and a cycle Bambuddy cut short for a
    print, or that the user stopped by hand, is not counted against the unit --
    so a farm that dries between queue jobs is unaffected. The threshold field
    now warns below 20%, and every cycle end logs the unit's temperature and
    humidity, which is what made this diagnosable.

    The same bundle showed unrelated tasks failing with "database is locked",
    each inside a 30.000-second Discord connect timeout. Alarms are raised from
    inside the loop that records sensor history, at a point where the new rows
    are added but not committed; the first read in the notification path flushed
    them to satisfy itself, opening a write transaction, and the provider was
    then contacted over the network with that transaction still open. SQLite
    allows one writer and 30 seconds outlives the 15-second busy timeout, so
    every other write in that window failed. The two reads that run before a
    provider is contacted no longer flush the caller's pending work, and the
    connect timeout is 5 seconds rather than 30 -- the body keeps the full 30,
    so image uploads on a slow uplink are unaffected. SQLite only; Postgres has
    no single-writer limit.
2026-08-15 14:45:16 +02:00
maziggy b0927b426c implemented pr (minor) feedback 2026-08-15 14:40:37 +02:00