Commit Graph
1791 Commits
Author SHA1 Message Date
maziggy 29311cab2b fix(backup): refuse prometheus_enabled when the backup has no token either (#2656)
The companion-credential rule has five conditions, and the second one -- "the
    backup itself carried a usable credential" -- was applied to all five pairs. It
    should not be. It is what stops the rule over-refusing an anonymous MQTT broker
    or an anonymous LDAP bind, both of which are working configs: there, an empty
    credential in the backup means the restore is not producing anything weaker
    than what was backed up.

    For prometheus_enabled it does not transfer. An empty prometheus_token removes
    /api/v1/metrics' only gate, so the exposure is a property of the toggle, not of
    a downgrade relative to the backup -- and prometheus_token is optional, so a
    backup taken on an instance that enabled Prometheus without ever setting one
    carries the toggle and no usable token. That payload skipped the refusal
    entirely: not a candidate, so the local-state pass never ran, and the blocklist
    quietly dropped the token key. On a token-less target the result was
    prometheus_enabled=true, no token row anywhere, and a full unauthenticated
    metrics dump -- the same hole the rule was written to close, reached from the
    likelier of the two directions.

    So condition 2 is now per-pair: an exposure class (prometheus) that skips it and
    is judged on local state alone, and an availability class (mqtt, ldap, ha,
    virtual_printer) that keeps it. Nothing else changes -- the local-state pass
    already stands down when the instance has its own credential, when HA_TOKEN is
    in the environment, and when the toggle is already on locally, so "the exposure
    pre-dates this restore" still holds and refusals still get no tally increment.

    One wording consequence: an exposure toggle can now be refused on a payload with
    no credential-like key in it at all, where the shared caveat would have read "0
    credential-like key(s) will be skipped". That case gets its own preview detail
    code, settingsCompanionOnlyWillSkip, in all 13 locales.

    Tests: 6 that fail pre-fix -- the token key absent and blank at the unit level,
    the new preview wording, and the integration test through the real endpoint for
    both payloads (200 with a full metrics body before this, 404 after). Plus 3
    controls, because over-refusal is still the real risk: the exposure route must
    still stand down for a local token and for an already-on toggle, and the
    availability class must still let a credential-less mqtt/ldap/virtual_printer
    toggle through. The anonymous-broker and anonymous-bind controls are unchanged
    and still pass.
2026-08-15 14:15:13 +02:00
maziggy 10c9e53e84 docs(backup): give the real reason cloud profiles are not restorable (#2656)
Both docstrings said the collector never writes `cloud_profiles/*.json`. That
    was true when they were written and stopped being true at `455a9e4b` (#2717,
    "collect cloud profiles from every connected account"), which is this
    branch's rebase base — so the PR was shipping a stated reason its own base
    had invalidated.

    The real reason is the one the PR body now gives: restoring a preset means
    writing to a Bambu or Orca Cloud account, which is a different operation from
    every other category here. Those land in the local database, or on a printer
    the instance already owns.

    Checked that nothing in the restore path is confused by the new files — the
    category globs don't reach `cloud_profiles/`, and it stays out of
    `RestoreCategory`.
2026-08-15 14:14:58 +02:00
maziggy 06038ecb15 fix(backup): require settings:update to restore the settings category (#2656)
The restore endpoint was gated on `github:restore` alone, and the settings
    category rewrites arbitrary non-auth `Settings` rows. Backup and Settings are
    separate permission groups, so a role holding only Backup could change
    settings it cannot change through `PUT /api/v1/settings/`, the endpoint that
    owns them.

    The inconsistency is ours rather than an inference: this module already makes
    exactly this argument — it is why the four protected auth keys are refused
    outright — and `library.py` sets the precedent of elevating a route to
    `settings:update` for the same reason.

    Gated per-category rather than by demoting `github:restore` wholesale, so it
    stays narrow and doesn't presume the answer for `spools:*`, `archives:*` and
    `kprofiles`. That broader permission-model question goes to the maintainer in
    the PR reply.

    `current_user is None` only means auth is disabled — `github:restore` is in
    `_APIKEY_DENIED_PERMISSIONS`, so an API key never reaches the route body.

    No frontend change: `request()` puts the 403's `detail` on the Error, and the
    modal already renders `restoreMutation.error.message` in its red block, so
    the user sees the missing permission named.

    Tests: 1 regression (a Backup-only role gets 403 and `run_restore` is never
    awaited) + 3 controls (the same role still restores the other three
    categories; a role holding both permissions still restores settings; auth
    disabled is unaffected). The regression confirmed failing against the pre-fix
    route. `_create_config` gained an optional token so it works under auth.
2026-08-15 14:14:43 +02:00
maziggy 5772523003 fix(backup): stop two backed-up K-profiles claiming one live slot (#2656)
`_match_kprofile` ends in a single-candidate fallback, and the per-nozzle
    loop called it once per entry with no record of which live profiles were
    already taken. Two backup entries sharing a `filament_id` and matching on
    neither `setting_id` nor `name` both resolved to the same live profile, both
    got the same `cali_idx`, and both went into the batch — so the second
    overwrote the first on the printer while the tally counted two restored.

    Reachable in the ordinary way: the user deletes one of a pair after the
    backup, and the delete-then-add re-key this code already reasons about is
    exactly what strips the `setting_id` match.

    Fix: thread a `claimed` set of slot ids through the loop; a live profile can
    only stand in for one entry. A displaced entry falls through to
    `cali_idx: -1` — add-as-new is the safe outcome — and folds into the
    existing `kprofilesUnmatched` note rather than earning a new code.

    The single-candidate fallback is still judged against every candidate rather
    than the unclaimed ones. Two live profiles for one filament are ambiguous
    whether or not another entry has taken one, and narrowing to "available"
    would turn a guess the code deliberately refuses into a match.

    Tests: 2 regression (the displaced entry is added rather than aliased, and
    keeps its own setting_id) + 2 controls (two genuine matches keep their own
    slots; a claimed slot does not make an ambiguous pair matchable). Both
    regressions confirmed failing against the pre-fix service.
2026-08-15 14:14:30 +02:00
maziggy 2d56ac9215 fix(backup): page Gitea's tree off what came back, not what we asked for (#2656)
The pager computed `seen = (page - 1) * 1000 + len(entries)`, taking the
    requested `per_page` as fact. Gitea clamps `per_page` to
    `MAX_RESPONSE_ITEMS`, which defaults to 50. So on a default install page 1
    returns 50 entries and sets `seen` to 50, then page 2 sets it to 1050 —
    which clears any `total_count` under 1050. The loop returns `success: true`
    holding the first 100 entries of a much larger tree.

    The restore then reads every missing path as "category not present in this
    commit" and skips it silently, which is precisely the failure this override
    was written to prevent. Same class as the GitLab pager fix, in the one
    direction that got left behind.

    Fix: accumulate `seen += len(entries)`. A genuinely over-cap tree still
    hard-fails rather than truncating; the cap is a page count, not a file
    count, because the page size is the server's choice.

    Test: a 120-entry tree served 50 at a time reaches its last entry, in three
    requests. Confirmed failing against the pre-fix backend — it stopped after
    two pages and reported 100 entries as the whole tree.
2026-08-15 14:14:17 +02:00
maziggy 5015b02760 fix(backup): don't let an old backup commit blank an archive's owner (#2656)
`created_by_id` and `deleted_at` both went into the archive `fields` dict
    unconditionally, via `entry.get(...)`. A backup commit taken before the
    collector wrote those keys carries neither, so `.get` yielded None for both
    and the overwrite branch — a blanket `setattr` over every key — wrote NULL
    over a live owner.

    That is exactly the failure carrying `created_by_id` was added to fix, only
    now inflicted on rows that were fine: `_ensure_archive_visible` fails closed
    on a NULL owner, so the archive 404s for the person who owns it. It emitted
    no note either, because `archivesOwnerCleared` only fires for an id that
    isn't in `valid_users`, not for an absent key — and the row still counted as
    restored. `deleted_at` had the mirror problem: an old commit silently
    un-deleted, since `archivesUndeleted` reads the same absent value.

    Absent is not the same as explicitly null. Both keys now only enter `fields`
    when the entry actually carries them, so an old commit leaves the column
    alone on overwrite and a current one can still say "this archive has no
    owner" or "this archive is live". Same shape as the tag-column rule: don't
    clear what the backup doesn't know about.

    Behaviour change to an existing test, called out deliberately:
    `test_overwrite_undeletes_a_locally_deleted_archive_and_says_so` now has to
    put `deleted_at: None` in the entry to mean it.

    Tests: 2 regression (owner and deleted_at both left alone by a key-less
    entry) + 2 controls (an explicit null is still honoured, with its note).
    Both regressions confirmed failing against the pre-fix service.
2026-08-15 14:14:04 +02:00
maziggy be17b62480 fix(backup): halve the provider round-trips, and stop losing commit metadata (#2656)
The three remaining review items, all in the read path.

    E1 — four provider calls where two would do. preview() called list_commits
    twice: once inside _resolve_ref to turn HEAD into a SHA, once more at limit=20
    purely to find the entry describing that same SHA. And list_tree's recursive
    tree GET was thrown away, so fetch_files immediately fetched the identical tree
    again to map path -> blob SHA. _resolve_ref now returns the entry it already
    has, and list_tree returns its blob_shas map for fetch_files to take as an
    optional argument. GitLab reads files by path and ignores it.

    E2 — `commit: null` for a ref outside the 20 most recent. Two causes, and the
    second is the one that actually bit: REF_PATTERN accepts a 7-character ref while
    providers return the full 40, so the exact `==` in the scan never matched an
    abbreviated SHA *even when the commit was in the window*. Fixed by prefix
    comparison, plus a get_commit(ref) on the GitHub and GitLab backends for the
    genuinely-outside-the-window case. Gitea and Forgejo inherit GitHub's. Still
    best-effort: it is a subject line and a date, so a failed lookup renders the
    preview without them rather than failing it.

    E7 — the two tree readers disagreed, and each was wrong in the other's
    direction. GitHub's recursive trees endpoint is not paginated and signals
    overflow with truncated=true, which _blob_shas_at hard-fails on. Gitea and
    Forgejo *do* page that endpoint, and inherited that single GET unchanged — so a
    large backup repo returned only the first page and every category beyond it
    looked absent from the commit. GiteaBackend now has its own paging
    _blob_shas_at. GitLab had the mirror-image bug the review did not name: at its
    50-page cap it exited through the while condition and returned success: True
    with a silently partial path list. Both now fail loudly, which is what the
    GitHub version was always doing.

    Both halves of E7 are the same failure the module already refuses to allow: a
    restore that skips categories and calls it "not present in this backup commit".

    24 new or changed tests, all failing against this commit's parent.
2026-08-15 14:13:51 +02:00
maziggy 812a70f326 fix(backup): stop overwrite writing a spool's other tag key (#2656)
Four of the review's smaller items.

    E6, the substantive one. tag_uid and tray_uuid are both in the overwrite setattr
    loop, so a spool matched on one key got the backup's *other* key written onto it.
    Neither column has a unique constraint (models/spool.py, and no unique index in
    the migrations), so nothing errors — a duplicate tag simply appears, after which
    _find_spool's .scalars().first() is non-deterministic and an AMS tag lookup
    resolves to an arbitrary one of the two spools. The same loop could also clear a
    tag the user had scanned since the backup was taken, when the backup entry held
    None.

    _find_spool now reports which key matched, and _guard_tag_overwrite drops a tag
    column from the write when the incoming value is empty and the local row has one
    (the backup predates the scan, so the local tag is the newer fact) or when
    another local spool already holds it. Announced in the tally the way the archive
    un-delete case already announces itself, rather than done silently — the
    spoolTagKept locale key landed with the rest of the i18n block last commit.

    E5. The Restore button is hidden without github:restore. All three endpoints are
    gated on it server-side, so the modal 403s on its first preview; offering the
    button is offering an action that cannot work. Button only — the card stays
    visible, since configuring backups is a separate permission — and hasPermission
    returns true with auth off, so a single-user instance is unaffected.

    E3. models/github_backup.py: the trigger comment said manual/scheduled; this PR
    added a third value.

    E4. ha_token_from_env: recommending no change, with the reasoning recorded as a
    test rather than left in a review thread. It is built only in the settings GET
    response, is absent from AppSettingsUpdate, and is therefore never a Settings
    row — it cannot reach a backup, so an allowlist entry would be dead code. Worse,
    a name-shaped exception to a belt-and-braces denylist is a live hole: an
    attacker-authored settings/app_settings.json could get a *token*-named row
    written by choosing that name.

    4 unit tests and 1 frontend test that fail against this commit's parent, plus 4
    controls: a free tag is still written, an unchanged tag is not reported as kept,
    an insert is unaffected, and the button still shows with auth disabled.
2026-08-15 14:13:35 +02:00
maziggy 7d2cf440b1 i18n(backup): make the restore notes and preview details translatable (#2656)
A German user got a translated modal with "Not present in this backup commit" in
    the middle of it. Every tally note and preview caveat was a server-built English
    sentence rendered verbatim.

    Follows the backup.pathCheck contract already in use one card down in the same
    component, deliberately rather than inventing a second convention: the server
    sends a `code` plus typed `params` and carries the English along as the
    fallback, and the client renders
    `t(`...${code}`, { ...params, defaultValue: message })`. The defaultValue arm is
    what keeps a newer backend's unfamiliar code readable instead of printing the
    raw key — covered by its own test.

    Shapes:
    - notes: list[str] -> list[GitHubRestoreNote] {code, params, message}. Breaking,
      but the field is unreleased in this same PR.
    - GitHubRestorePreviewCategory gains detail_code / detail_params; `detail` stays
      as the English fallback.
    - _CategoryTally.note(code, message, **params), deduped on (code, params) rather
      than on the rendered text, so two offline printers both keep their names. The
      20-note cap is unchanged.

    28 new leaves across 13 locales: 20 notes.* and 8 details.*. `noData` collapses
    the four per-category "No X data in this backup" strings into one, since the
    category heading already renders beside it. Counts use single-form {{count}} in
    the existing "N record(s)" style rather than i18next plural suffixes — nothing in
    this block uses _one/_other and the parity script has extra rules for them.
    Parity holds at 5737 leaves in all 13 locales.

    Deliberately out of scope, and worth saying so rather than leaving it to look
    like an oversight: result.message, the commit-picker subject lines and the HTTP
    error strings stay English. Those also originate in the provider backends, so
    code-ifying them widens the diff well past the restore service.

    spoolTagKept is added here with the rest of the locale churn but is not emitted
    until the next commit, so the 13-locale change lands once.
2026-08-15 14:13:19 +02:00
maziggy 946d6457c2 fix(backup): carry the owner across, or restored archives are invisible (#2656)
Neither _collect_archives nor _restore_archives touched created_by_id, so every
    restored archive row landed NULL. That column is not attribution, it is what the
    access check runs on: _ensure_archive_visible (api/routes/archives.py) fails
    closed on NULL — a 404 for any caller without archives:read_all — and the list
    paths filter created_by_id == user.id. On a multi-user instance the tally
    therefore reported archives restored while the person who owns them could
    neither list nor open them.

    Same shape as the deleted_at fix, and the same remedy: the collector records the
    key next to deleted_at, the restore mirrors the printer_id/project_id pattern
    exactly — one hoisted select(User.id), a membership test per row, an unknown id
    coerced to None rather than failing the row, and one de-duplicated note. It is in
    the overwrite setattr loop too, so overwrite keeps meaning "make local match the
    backup". Additive on the backup side, so older backups still restore; they just
    cannot know the owner.

    Clearing the id is not silent-safe, so the note says what it costs: those
    archives are visible only to users with archives:read_all until an admin
    reassigns them.

    Caveat recorded in a comment and raised in the PR, not decided here: this is the
    one place the module reuses a raw backup id, against its own rule. Validating it
    means a *stale* id clears rather than pointing somewhere wrong, but a live id
    belonging to a different person on a different instance would still collide.
    Collecting username and resolving on that would close it.

    6 unit tests and 1 integration test that all fail against the parent commit,
    plus 2 controls that pass either way — a backup with no created_by_id key still
    restores, and a second operator still gets a 404.
2026-08-15 14:12:50 +02:00
maziggy de8a86c40f fix(backup): never restore a toggle whose credential can't come with it (#2656)
A settings restore refuses to write anything credential-shaped, but wrote the
    switches that depend on those credentials like any other key. Restoring the two
    halves apart is not a partial restore, it is a downgrade.

    The sharp case is Prometheus. /api/v1/metrics is on PUBLIC_API_ROUTES and its
    only gate is `if token:`, so an empty or absent token means no authentication at
    all. prometheus_token matches the `token` hint and is refused; prometheus_enabled
    is an ordinary key and was written. On an instance that never enabled Prometheus
    there is no local token row, so overwrite-*off* alone was enough to publish the
    whole metrics body to anyone who could reach the port. The new integration test
    shows exactly that: 200 with a full unauthenticated body before, 404 after.

    Four more pairs are the same shape and break an integration rather than open one:
    ldap_enabled/ldap_bind_password, mqtt_enabled/mqtt_password, ha_enabled/ha_token
    (with an HA_TOKEN env arm, since get_homeassistant_settings prefers the
    environment over the row), and virtual_printer_enabled/virtual_printer_access_code
    — the last largely vestigial post-migration, included for consistency.

    A toggle is refused only when all five hold: the payload value is truthy, the
    backup carried a non-empty companion credential, that credential is denylisted,
    this instance has no usable value for it, and the toggle is not already on
    locally. The second condition is what keeps the rule honest — an anonymous MQTT
    broker and an anonymous LDAP bind are legitimate configs that pass empty
    credentials straight through, and without it both would be false positives. With
    it, the rule fires only when the restore would produce a config weaker than both
    the backup and the local instance. A present-but-blank prometheus_token row
    counts as unusable, since that is precisely the `if token:` hole.

    The rule needs the payload *and* local database state, which the old static
    _count_items could not see, so preview and restore now share one classifier:
    _plan_settings() runs a single SELECT over both halves of every candidate pair
    before anything enters the session, and returns the three refusal buckets.
    preview() takes the session the route already has. _is_skipped_setting_key is
    gone rather than having its docstring corrected as asked: a name is no longer
    enough to decide, so the union predicate had no caller left.

    Also implements the review's third ruling — the tally counts what the preview
    counted, and refusals live in the notes. Two `skipped += 1` increments are
    dropped (blocked, protected) and the companion refusal adds none; the value-is-
    None and overwrite-off skips stay, because they depend on the run's flags, which
    the preview cannot see. restored + skipped + failed now equals the item count the
    user was shown — off by three before.

    Behaviour change called out for review: test_credential_keys_are_never_restored
    and test_auth_settings_are_never_restored asserted skipped == 2 and 4; both are
    now 0, which is the point of the ruling.

    16 new unit tests plus 2 integration tests. Nine of them are controls, because
    over-refusal is the real risk of this change — the anonymous-broker and
    anonymous-bind guards are load-bearing, not decoration.
2026-08-15 14:12:28 +02:00
maziggy 049a638679 y fix(backup): don't count a stale selection, and say when archive links are dropped (#2656)
Two smaller restore-path fixes from the same review.

    The modal's footer counted `selected` raw while the checkboxes rendered
    `selected && isAvailable`. Switching commits keeps `selected` on purpose (it is
    only pruned once the new preview lands), so for as long as the new commit's
    preview was in flight — with the category list replaced by its spinner — the
    footer still read "2 selected" over an enabled Restore button, and clicking it
    restored the newly-picked commit with the previous commit's categories, none of
    which the user had seen an item count for. The count and the POST body now come
    from one `selectedCategories` memo gated on availability, exactly as the
    checkboxes are, so both go empty until the preview lands.

    Restoring Spool inventory without Print archives leaves archive_id_map empty,
    so every usage -> archive link is nulled even where the archive exists locally.
    It can't be resolved here (the archives payload isn't fetched for a category
    that wasn't selected) and a later archives-only restore won't repair it either,
    since the usage dedupe key doesn't include archive_id and those rows read as
    already-present. So it gets a note naming the remedy while the user can still
    redo the run with both categories ticked.
2026-08-15 14:12:11 +02:00
maziggy e0cfa90be7 fix(backup): reconnect the MQTT relay after restoring mqtt_* settings (#2656)
The relay reads its broker config once, when configure() is called — which is
    why the settings PUT handler reconfigures it after writing those rows
    (api/routes/settings.py:246). The restore wrote the rows and stopped there, so
    the relay stayed on the pre-restore broker until the next backend restart while
    the UI showed the restored values: the one way a settings restore could look
    applied without being applied.

    _restore_settings now reports the keys it actually wrote, and run_restore
    reconfigures the relay from the committed rows when any of them is an mqtt_ one.
    Three details worth keeping:

    * it runs after the commit, because configure() drops the connection and
      rebuilds it — not something to do on values a later failure could roll back;
    * it is keyed on written, not merely present: a key skipped for overwrite=off
      or by the credential blocklist must not trigger a reconnect;
    * mqtt_password is never restorable, so configure() gets the row already in the
      database and an unchanged broker keeps working.

    A broker that refuses the new config is noted on the settings tally ("restart
    Bambuddy") rather than failing the restore, matching the PUT handler's
    best-effort handling of the same call.
2026-08-15 14:11:57 +02:00
maziggy 92de15ab8e fix(backup): release the SQLite writer before the K-profile MQTT phase (#2656)
_apply ran archives and spools first, which autoflushes their INSERTs and so
    opens SQLite's single write transaction, then called _restore_kprofiles —
    which awaits get_kprofiles per printer per nozzle at timeout=5.0 with
    max_retries=3, i.e. up to ~15 s each against a printer that ignores the
    request. The commit only came afterwards, in run_restore. busy_timeout is 15 s
    (core/database.py:21), so a restore covering a couple of unresponsive printers
    held the writer past it and unrelated writes elsewhere in the app failed with
    "database is locked".

    Commit the database categories before the MQTT phase starts. The K-profile
    work is not in that transaction anyway — it leaves over MQTT — so the only
    thing lost is rolling those categories back when a K-profile send fails, and
    that rollback was never the right behaviour: extrusion_cali_set has already
    reached the printer by then, so undoing the database half would just make the
    two disagree.
2026-08-15 14:11:44 +02:00
maziggy 5fd9cbd744 fix(backup): never restore the auth policy settings from a backup (#2656)
_collect_settings exports every Settings row minus two credential keys, so
    auth_enabled / advanced_auth_enabled / local_login_enabled / setup_completed
    all travel in a backup, and none of them are credential-shaped enough for
    _SECRET_KEY_HINTS to catch. Writing them back was the one part of a settings
    restore that changed who can reach the instance rather than how it behaves:

    * auth_enabled=false — from any backup taken before auth was turned on —
      disabled authentication. core.auth caches only the enabled=True result, on
      a 30 s TTL, precisely so staleness fails closed; set_auth_enabled pairs its
      write with invalidate_auth_enabled_cache(). The restore did neither, so it
      left the stored value the open one.
    * local_login_enabled=false walked straight past the #1589 refusals in
      update_settings (no enabled OIDC provider / no OIDC link on the caller),
      which exist to stop exactly that lockout.
    * /github-backup/restore is gated on GITHUB_RESTORE alone, so honouring these
      keys made that permission a way to rewrite auth config without
      SETTINGS_UPDATE.

    Flipping an existing row needed overwrite_existing, so the odds were lower
    than the severity. Both refusals now share _is_skipped_setting_key so the
    preview's item count still matches what a restore writes, and the skipped
    keys get their own note pointing at the auth UI rather than being folded in
    with the credential ones.
2026-08-15 14:11:31 +02:00
maziggy baa82f781a fix(backup): resolve K-profile cali_idx live instead of reusing the backup's (#2656)
Restoring K-profiles addressed extrusion_cali_set at the cali_idx recorded in
    the backup. If that slot no longer existed on the printer the write was
    silently dropped and the restore still reported the profile restored.

    Not an edge case: Bambuddy's own K-profile editor is what re-keys the slot.
    On a single-nozzle printer an edit is delete-then-add, so any edit between
    backup and restore reproduces it.

    Found testing on an X1E. Backup held cali_idx 8151; an edit through the UI
    re-keyed the profile to 4606; the restore published cali_idx 8151, the printer
    ignored it, and the tally read "1 restored" while the k-value stayed put.
    Resending the identical payload with cali_idx 4606 applied, isolating the
    stale index as the cause.

    Fix mirrors the natural-key matching spools and archives already use, which
    the module docstring already promised but scoped to spool.id and
    print_archives.id. Before writing, read the live profiles for the nozzle and
    match on filament_id + setting_id, falling back to filament_id + name, then to
    the sole candidate for that filament. Send that profile's current cali_idx;
    where nothing matches send -1 so the printer adds a new profile instead of
    addressing a dead slot, and say so in the tally. A failed read degrades to
    adding rather than aborting.

    Also corrects the tally note. The printer does acknowledge extrusion_cali_set
    -- it answers with a result/reason pair -- so "published without
    acknowledgement" was false. It reports "fail" on writes that land, though, so
    the note now says the acknowledgement is unreliable rather than absent.
    Consuming result is left to a follow-up.

    Re-verified on the same X1E: perturbed to k=0.061, restored from the commit
    carrying the stale slot, payload went out with cali_idx 4606 and the printer
    read back 0.027.
2026-08-15 14:11:02 +02:00
maziggy 449924f887 feat(backup): restore selected categories from a Git backup commit (#2656)
The Git backup feature was push-only: there was no equivalent of the local
    backup's Restore button, so recovering meant hand-downloading JSON files from
    the repository. This adds the read side.

    Providers gain list_commits / list_tree / fetch_files on the GitProviderBackend
    ABC. GitHub implements them against the Git Data API and Gitea/Forgejo inherit
    that unchanged; GitLab overrides for its own REST shape, including tree
    pagination and subgroup path encoding. fetch_files is batched so the path ->
    blob SHA lookup happens once per restore rather than once per file, and uses the
    blobs API rather than contents because contents silently inlines only the first
    1 MB.

    The new GitHubRestoreService resolves HEAD to a concrete SHA up front, so a
    preview and the restore that follows act on the same commit even if a scheduled
    backup lands in between. Categories are applied archives -> spools -> settings
    -> kprofiles: archives first because spool usage history references archive_id,
    K-profiles last because they leave the database and publish over MQTT.

    Restores never reuse the backup's primary keys. spool.id and print_archives.id
    are bare autoincrement columns, so ids from an old backup very likely belong to
    unrelated rows today; rows are matched on natural keys (tag_uid, then
    tray_uuid, then a descriptive composite for spools; content_hash or filename
    plus started_at for archives), inserted without an explicit id, and an
    old_id -> new_id map rewrites the foreign keys in spool usage history.
    created_at is carried across on insert so restoring the same backup twice
    matches instead of duplicating. Dangling printer/project links are cleared and
    reported rather than failing the row.

    Settings restore re-applies the collector's credential denylist on the read
    side, plus a pattern guard, because a backup taken before that denylist existed
    can still contain secrets. Restored archives are metadata-only: the 3MF and
    thumbnail bytes are not in a Git backup and print_archives.file_path is NOT
    NULL, so inserted rows get an empty path and the UI says so.

    Backup and restore take a mutex against each other; both write the same tables
    and talk to the same printers. Restores are logged as GitHubBackupLog rows with
    trigger="restore", which needs no migration and surfaces them in the existing
    History card.

    Cloud profiles are deliberately not a restore category. The collector never
    actually writes cloud_profiles/*.json - it reads a "setting" list key the Bambu
    Cloud API does not return - and the preset list it would write carries no
    setting payload. Filed separately.

    Permission github:restore already existed and is granted to Administrators, so
    no permission changes were needed.

    Tests: 125 new backend tests (provider reads across all four providers, the
    per-category appliers, the API endpoints) and 13 frontend tests. Full suites
    pass with no regressions; the 35 backend failures on Windows are byte-identical
    with and without this branch.
2026-08-15 14:10:10 +02:00
maziggy 190d4f2ce8 Add temperatures to the streaming overlay and a URL builder (#1422)
The overlay at /overlay/{printer} draws live print data over a
    full-screen camera view for OBS, a wall display or any browser source.
    It has been tunable since it shipped -- which fields, what size, what
    frame rate -- but only through query parameters documented in the wiki,
    and temperatures were not among the fields on offer. The request asked
    for temperatures first and for the field set to be selectable in the web
    UI; this addresses both.

    Nozzle, bed and chamber readings join the list. The target is drawn only
    while the heater is still climbing, so a settled hotend reads "220°C"
    for the rest of the print instead of the noisier "220 / 220°C" -- 219.6
    against a target of 220 rounds to the same number, and repeating it says
    nothing. Both nozzles appear on a dual-nozzle machine. They are drawn
    whether or not a print is running, because a preheating printer is
    exactly when they are worth watching, and each reading appears only when
    the printer genuinely reports one: chamber temperature stays absent on
    P1 and A1 models, which publish a chamber_temper with no sensor behind
    it, so the overlay never puts a measurement on screen that does not
    exist. Labels reuse the heater chart's strings rather than inventing a
    second vocabulary for the same three things.

    The feed sends an allow-list rather than the temperatures dict. That
    dict doubles as the MQTT client's working memory -- derived heater flags
    and private target-set timestamps live alongside the readings -- and an
    overlay token is a narrower grant than a login, so it gets exactly what
    the overlay draws and does not pick up fields as the dict grows. The
    same chamber-sensor gate the full status payload already applies is
    applied here. The integration test that asserts the payload's exact key
    set, which exists to catch that surface widening silently, is updated
    deliberately.

    Temperatures are not in the default field set, so an overlay URL already
    pasted into a scene renders identically after upgrading.

    Settings -> API Keys -> Streaming Overlay now builds the URL: printer,
    field checkboxes, size, frame rate, camera toggle, an optional token,
    and a copy button. It persists nothing and calls nothing new -- the URL
    is the configuration, which keeps a scene reproducible by copy-paste and
    lets two displays show different fields off one token. Fields are
    emitted in the overlay's own top-to-bottom order rather than click
    order, and parameters left at their default are omitted, so the same
    selection always produces the same URL. The preview alongside it stays
    off until asked for: an always-live iframe would hold a subscriber on
    the printer's single camera connection for as long as the settings tab
    stayed open.

    The preview needed one narrow security-header change. Every SPA route
    sent frame-ancestors 'none', which is stricter than the SAMEORIGIN in
    X-Frame-Options beside it and refuses even a same-origin frame, so the
    preview showed Firefox's "another site has embedded it" page instead of
    the overlay. The overlay path now sends 'self', mirroring /gcode-viewer,
    which admits a framer only on this origin -- Bambuddy's own UI. Every
    other path keeps 'none', and embedding the overlay from another host
    still requires TRUSTED_FRAME_ORIGINS.
2026-08-15 14:07:27 +02:00
maziggy 268940573d Stop a drying cycle reporting itself finished a minute in (#2759)
Starting the dryer on an AMS 2 Pro holding two PETG and two PLA spools
    and picking PLA showed "PLA @ 45°C" for about a minute and then switched
    to "PETG @ 65°C" for the remaining twelve hours.

    Bambu never echoes back which filament or temperature a cycle is
    running, so the badge reads the target cached when the command went out,
    and that cache had been dropped. Between accepting the command and
    settling its countdown the firmware publishes one update with the
    remaining time at zero while the unit is still in its Checking phase --
    the reporter's log has 720, then 0, then 719, and four seconds later the
    same unit's info hex decodes to dry_status 2, Drying. The falling-edge
    detector read that zero as the cycle ending. Losing the cached target
    left the badge to guess from the first loaded slot, which was PETG, and
    its RFID-recommended 65°C. The same false ending fired
    on_drying_complete, so anyone with smart-plug auto-off-after-drying
    switched on had power scheduled to cut one minute into a twelve-hour
    dry; the reporter had it off, which is the only reason this reads as a
    cosmetic bug.

    A remaining time of zero now ends a cycle only when the unit is not also
    reporting an active phase. dry_status comes from the same info hex
    already parsed a few lines above, so this costs nothing to check.
    Stopping and Error are deliberately not treated as active -- those
    should end it -- and a unit that reports no phase at all still ends its
    cycles, so the gate can only ever suppress on positive evidence that the
    cycle is live. A suppressed edge leaves the remembered dry_time alone,
    exactly as the #1462 absent-value skip does, so the push that really
    ends the cycle still sees a non-zero previous.

    The fallback guess is tightened to match. On a mixed unit the first tray
    is evidence of nothing, and naming a temperature the cycle is not using
    is worse than naming none, so it now answers only when every loaded
    spool is the same filament and otherwise leaves the badge showing the
    countdown alone. Both the websocket and REST status builders carried
    their own copy of that loop; they now share one helper, which also takes
    the temperature from the first slot that carries an RFID one rather than
    giving up when slot 1 holds a third-party spool.
2026-08-15 14:07:01 +02:00
maziggy 0d8d67c866 Say when AMS drying was running and a print never started (#2758)
Dispatching to an X2D with two AMS units mid-drying failed silently: the
    file uploaded, the printer accepted it and stayed idle. The watchdog waits
    for an active state or HMS_MQTT_VERIFY_FAILED, and a drying refusal is
    neither, so it timed out, re-uploaded the whole 3MF twice more, and closed
    with advice about the printer screen and the SD card. Studio, asked
    directly, said it could not start the job because of the drying.

    Latch the AMS dry_time telemetry across both watchdog phases and name the
    units in the give-up message, plus an INFO log on every failed window so
    the correlation reaches a support bundle from the first attempt.

    Detection only, no gate. These models support drying CONTINUING through a
    print (supports_drying_while_printing covers X2D from 01.01.00.00), so
    drying is not incompatible with printing and stopping it before every
    dispatch would tear down cycles the hardware is happy to run. One of the
    two units was also drying without its external PSU, which would make this a
    power budget problem at start-of-print calibration rather than a drying one
    -- dry_sf_reason 1/8 exist for exactly that. The message names both
    possibilities rather than asserting one.

    Also correct _sync_drying_state's docstring, which claimed to adopt drying
    it did not start; it only prunes. Behaviour unchanged -- populating it would
    let the scheduler stop a cycle the user started by hand.
2026-08-15 14:06:29 +02:00
maziggy 1136ce33ab Add CAP_NET_BIND_SERVICE everywhere the service is defined (#2549)
The Virtual Printer binds 990 and 322, below 1024, which a service running
    as a normal user may not do without CAP_NET_BIND_SERVICE. Without it the
    rest of Bambuddy works and only the VP is dead -- sockets never open, the
    slicer never finds the printer, and the sole trace is one journal line.

    332a7c6ac added the line to install/install.sh in March under the heading
    "Fix install.sh missing AmbientCapabilities". Three other places define the
    same unit and none of them got it: the manual template, the combined
    Bambuddy + SpoolBuddy installer, and the unit the wiki tells you to paste.
    The wiki additionally claimed the capability was always included.

    Also diagnose it. The VP diagnostic reported only that nothing was listening
    on 990, which reads identically to a port conflict. It now checks CapEff for
    the capability and names it as the cause -- but stays quiet when the port is
    answering (an iptables REDIRECT is the documented alternative and that host
    works) and when the capability is held (the port is down for another reason
    and blaming this would misdirect). Skips where there is no procfs rather
    than putting a systemd instruction in front of a macOS user.
2026-08-15 14:06:08 +02:00
maziggy 19fb43752c Report a refused AMS filament setting instead of discarding it (#2756)
Configuring a slot publishes ams_filament_setting and the printer answers
    with a verdict. The answer was received and dropped at DEBUG, so a refusal
    left no trace at the level support bundles are collected at: the reporter
    saw six Configure Slot attempts on an X1C all return success, all read back
    by the #2582 verification as holding the previous profile, and no record of
    what the printer said about any of them.

    Promote a non-success response to INFO with result, reason, ams_id and
    tray_id. Refusals only -- unlike extrusion_cali_set (#2718) and
    ams_filament_drying (#1447) this command is not rare, since every spool
    assignment and K-profile re-apply sends one, so promoting each ack would
    bury the interesting line.

    The developer-mode probe is excluded: it sends this same command to the
    external slot expecting a refusal on P1 firmware, so promoting it would
    put an alarming line in every P1 bundle on every reconnect. Matched by
    sequence id, which user commands cannot collide with -- they publish a
    hardcoded "0".

    Diagnostics only; no change to which commands are sent or how they are built.
2026-08-15 14:05:45 +02:00
maziggy f18a4b787e Show the compose directory in the Docker update command (#2664)
The printed command only works from the directory holding the compose
    file, which is the thing the user came to the page not knowing. Adds a
    copy button, a saved Compose directory setting, BAMBUDDY_COMPOSE_DIR,
    and best-effort detection from a bind mount's host path.

    Compose records the directory on every container it creates, but reading
    that label needs the Docker socket mounted in — root-equivalent access
    for a convenience string. The mountinfo guess is a prefill only: its root
    field is relative to the mounted device, so a compose dir on its own
    mount loses that prefix, and nothing in the container can detect it.

    The field is restricted to path characters. It is the one setting whose
    purpose is to be pasted into a root shell, so "/opt/bambuddy; rm -rf /"
    would otherwise render as a plausible update command.
2026-08-15 14:03:44 +02:00
maziggy 914cbffdcd Show the Print Log's per-run cost and energy, and let users pick columns (#2636)
The list and update endpoints serialised field by field and never named
    cost / energy_kwh / energy_cost, so values Bambuddy had been recording
    all along went out as nulls. Both now validate from the ORM row, which
    removes the chance to omit a field rather than patching the three that
    were missing.

    Adds a Filament Used column plus a Columns picker for Cost, Energy,
    Energy Cost and Finished, persisted per browser.

    Also fixes the log view being unreachable with zero archives: the empty
    state ran before the view check, hiding a log that outlives the archives
    it refers to.

    ---

    Sort the Print Log by any column (#2636)

    Adds sort_by / sort_dir to the print-log endpoint, driven by clickable
    column headers. Server-side because paging is: ordering the rows the
    client holds would sort one page rather than the log.

    Empty values are held last in both directions — Postgres sorts NULLs
    high and SQLite low, so the same click would otherwise open on blanks
    on one backend and values on the other. id DESC breaks ties so paging
    through a low-cardinality sort can't repeat or skip a row.
2026-08-15 14:03:18 +02:00
maziggy 67e8a78cb8 Add auto-orient and auto-arrange to server-side slicing (#2548)
Both are per-slice checkboxes, off by default, forwarded as the sidecar's
    orient / arrange form fields. An unticked box is sent by omission: the
    sidecar treats any present value as truthy, so a literal "false" would
    have arranged every slice.

    Arrange unions with the #1493 cross-class decision rather than replacing
    it, and the per-plate slice-all loop is now keyed on the arrange flag
    itself — the project-wide collapse belongs to --arrange, not to the
    cross-class case. The loop also covers the embedded-settings path, whose
    crash-retry is suppressed there since a single --slice 0 retry would
    return one consolidated plate.
2026-08-15 14:02:31 +02:00
maziggy 06d898f0a9 Document prefer_filename_for_name in the OpenAPI schema
The param was a bare bool, so /docs showed an undocumented boolean on
    both upload routes. #2609 is about external integrations, and the
    interactive docs are where those callers look — a docstring only reaches
    someone reading the source. Wraps both in Query(False, description=...),
    matching how this file documents its other query params.

    Also records why these two routes take the flag per-request while the FTP
    review flow and virtual-printer dispatch derive it from the VP-scoped
    virtual_printer_archive_name_source setting, and drops the db_session
    fixture the four new tests requested but never used.
2026-08-15 14:01:14 +02:00
maziggy 7ecd190f8b Raise the chamber-temperature ceiling from 60 to 65 C
Every field that takes a chamber target stopped at 60: the per-filament
    chamber map and per-print override in Preheat & Heat Soak, the chamber
    quick-select presets, and the printer-card chamber control. 60 is the
    X1E's ceiling and the X1E was the only heated-chamber model when that
    limit was written; the H2 series and X2D heat to 65, so the top of their
    range was unreachable.

    The ceiling now lives in one constant per side (MAX_CHAMBER_TEMP_C in
    backend/app/utils/printer_models.py and frontend/src/utils/printer.ts)
    rather than as a literal at each call site. X1E firmware clamps a higher
    request to its own maximum, so a shared ceiling is safe.

    Also fixes a live bug at PrintersPage.tsx:7985: parsePresetTriple was
    bounded to 60 there, and it rejects the whole triple on any out-of-range
    entry, so a saved 65 preset would have silently reverted the printer
    card to the defaults while Settings still showed 65.
2026-08-15 14:00:43 +02:00
maziggy a8378b0e0e Merge remote-tracking branch 'upstream/dev' into feature/upload-prefer-filename-for-name 2026-08-15 14:00:15 +02:00
maziggy bd6ad1a338 feat: expose prefer_filename_for_name on bulk archive upload too
Maintainer review on #2610 flagged that upload-bulk would diverge from
    upload if only the single-file route got the flag. Applies to every
    file in the batch, same default-off behavior.
2026-08-15 13:59:07 +02:00
jmoore-skild bbd991510f fix(backup): refuse prometheus_enabled when the backup has no token either (#2656)
The companion-credential rule has five conditions, and the second one -- "the
backup itself carried a usable credential" -- was applied to all five pairs. It
should not be. It is what stops the rule over-refusing an anonymous MQTT broker
or an anonymous LDAP bind, both of which are working configs: there, an empty
credential in the backup means the restore is not producing anything weaker
than what was backed up.

For prometheus_enabled it does not transfer. An empty prometheus_token removes
/api/v1/metrics' only gate, so the exposure is a property of the toggle, not of
a downgrade relative to the backup -- and prometheus_token is optional, so a
backup taken on an instance that enabled Prometheus without ever setting one
carries the toggle and no usable token. That payload skipped the refusal
entirely: not a candidate, so the local-state pass never ran, and the blocklist
quietly dropped the token key. On a token-less target the result was
prometheus_enabled=true, no token row anywhere, and a full unauthenticated
metrics dump -- the same hole the rule was written to close, reached from the
likelier of the two directions.

So condition 2 is now per-pair: an exposure class (prometheus) that skips it and
is judged on local state alone, and an availability class (mqtt, ldap, ha,
virtual_printer) that keeps it. Nothing else changes -- the local-state pass
already stands down when the instance has its own credential, when HA_TOKEN is
in the environment, and when the toggle is already on locally, so "the exposure
pre-dates this restore" still holds and refusals still get no tally increment.

One wording consequence: an exposure toggle can now be refused on a payload with
no credential-like key in it at all, where the shared caveat would have read "0
credential-like key(s) will be skipped". That case gets its own preview detail
code, settingsCompanionOnlyWillSkip, in all 13 locales.

Tests: 6 that fail pre-fix -- the token key absent and blank at the unit level,
the new preview wording, and the integration test through the real endpoint for
both payloads (200 with a full metrics body before this, 404 after). Plus 3
controls, because over-refusal is still the real risk: the exposure route must
still stand down for a local token and for an already-on toggle, and the
availability class must still let a credential-less mqtt/ldap/virtual_printer
toggle through. The anonymous-broker and anonymous-bind controls are unchanged
and still pass.
2026-08-04 08:57:34 -04:00
jmoore-skild 733894aee6 docs(backup): give the real reason cloud profiles are not restorable (#2656)
Both docstrings said the collector never writes `cloud_profiles/*.json`. That
was true when they were written and stopped being true at `455a9e4b` (#2717,
"collect cloud profiles from every connected account"), which is this
branch's rebase base — so the PR was shipping a stated reason its own base
had invalidated.

The real reason is the one the PR body now gives: restoring a preset means
writing to a Bambu or Orca Cloud account, which is a different operation from
every other category here. Those land in the local database, or on a printer
the instance already owns.

Checked that nothing in the restore path is confused by the new files — the
category globs don't reach `cloud_profiles/`, and it stays out of
`RestoreCategory`.
2026-08-04 08:57:34 -04:00
jmoore-skild 8efa9c2945 fix(backup): require settings:update to restore the settings category (#2656)
The restore endpoint was gated on `github:restore` alone, and the settings
category rewrites arbitrary non-auth `Settings` rows. Backup and Settings are
separate permission groups, so a role holding only Backup could change
settings it cannot change through `PUT /api/v1/settings/`, the endpoint that
owns them.

The inconsistency is ours rather than an inference: this module already makes
exactly this argument — it is why the four protected auth keys are refused
outright — and `library.py` sets the precedent of elevating a route to
`settings:update` for the same reason.

Gated per-category rather than by demoting `github:restore` wholesale, so it
stays narrow and doesn't presume the answer for `spools:*`, `archives:*` and
`kprofiles`. That broader permission-model question goes to the maintainer in
the PR reply.

`current_user is None` only means auth is disabled — `github:restore` is in
`_APIKEY_DENIED_PERMISSIONS`, so an API key never reaches the route body.

No frontend change: `request()` puts the 403's `detail` on the Error, and the
modal already renders `restoreMutation.error.message` in its red block, so
the user sees the missing permission named.

Tests: 1 regression (a Backup-only role gets 403 and `run_restore` is never
awaited) + 3 controls (the same role still restores the other three
categories; a role holding both permissions still restores settings; auth
disabled is unaffected). The regression confirmed failing against the pre-fix
route. `_create_config` gained an optional token so it works under auth.
2026-08-04 08:57:34 -04:00
jmoore-skild cb6a4e6d88 fix(backup): stop two backed-up K-profiles claiming one live slot (#2656)
`_match_kprofile` ends in a single-candidate fallback, and the per-nozzle
loop called it once per entry with no record of which live profiles were
already taken. Two backup entries sharing a `filament_id` and matching on
neither `setting_id` nor `name` both resolved to the same live profile, both
got the same `cali_idx`, and both went into the batch — so the second
overwrote the first on the printer while the tally counted two restored.

Reachable in the ordinary way: the user deletes one of a pair after the
backup, and the delete-then-add re-key this code already reasons about is
exactly what strips the `setting_id` match.

Fix: thread a `claimed` set of slot ids through the loop; a live profile can
only stand in for one entry. A displaced entry falls through to
`cali_idx: -1` — add-as-new is the safe outcome — and folds into the
existing `kprofilesUnmatched` note rather than earning a new code.

The single-candidate fallback is still judged against every candidate rather
than the unclaimed ones. Two live profiles for one filament are ambiguous
whether or not another entry has taken one, and narrowing to "available"
would turn a guess the code deliberately refuses into a match.

Tests: 2 regression (the displaced entry is added rather than aliased, and
keeps its own setting_id) + 2 controls (two genuine matches keep their own
slots; a claimed slot does not make an ambiguous pair matchable). Both
regressions confirmed failing against the pre-fix service.
2026-08-04 08:57:34 -04:00
jmoore-skild 582a6b18bc fix(backup): page Gitea's tree off what came back, not what we asked for (#2656)
The pager computed `seen = (page - 1) * 1000 + len(entries)`, taking the
requested `per_page` as fact. Gitea clamps `per_page` to
`MAX_RESPONSE_ITEMS`, which defaults to 50. So on a default install page 1
returns 50 entries and sets `seen` to 50, then page 2 sets it to 1050 —
which clears any `total_count` under 1050. The loop returns `success: true`
holding the first 100 entries of a much larger tree.

The restore then reads every missing path as "category not present in this
commit" and skips it silently, which is precisely the failure this override
was written to prevent. Same class as the GitLab pager fix, in the one
direction that got left behind.

Fix: accumulate `seen += len(entries)`. A genuinely over-cap tree still
hard-fails rather than truncating; the cap is a page count, not a file
count, because the page size is the server's choice.

Test: a 120-entry tree served 50 at a time reaches its last entry, in three
requests. Confirmed failing against the pre-fix backend — it stopped after
two pages and reported 100 entries as the whole tree.
2026-08-04 08:57:34 -04:00
jmoore-skild be8e8d545f fix(backup): don't let an old backup commit blank an archive's owner (#2656)
`created_by_id` and `deleted_at` both went into the archive `fields` dict
unconditionally, via `entry.get(...)`. A backup commit taken before the
collector wrote those keys carries neither, so `.get` yielded None for both
and the overwrite branch — a blanket `setattr` over every key — wrote NULL
over a live owner.

That is exactly the failure carrying `created_by_id` was added to fix, only
now inflicted on rows that were fine: `_ensure_archive_visible` fails closed
on a NULL owner, so the archive 404s for the person who owns it. It emitted
no note either, because `archivesOwnerCleared` only fires for an id that
isn't in `valid_users`, not for an absent key — and the row still counted as
restored. `deleted_at` had the mirror problem: an old commit silently
un-deleted, since `archivesUndeleted` reads the same absent value.

Absent is not the same as explicitly null. Both keys now only enter `fields`
when the entry actually carries them, so an old commit leaves the column
alone on overwrite and a current one can still say "this archive has no
owner" or "this archive is live". Same shape as the tag-column rule: don't
clear what the backup doesn't know about.

Behaviour change to an existing test, called out deliberately:
`test_overwrite_undeletes_a_locally_deleted_archive_and_says_so` now has to
put `deleted_at: None` in the entry to mean it.

Tests: 2 regression (owner and deleted_at both left alone by a key-less
entry) + 2 controls (an explicit null is still honoured, with its note).
Both regressions confirmed failing against the pre-fix service.
2026-08-04 08:57:34 -04:00
jmoore-skild 158301ac8a fix(backup): halve the provider round-trips, and stop losing commit metadata (#2656)
The three remaining review items, all in the read path.

E1 — four provider calls where two would do. preview() called list_commits
twice: once inside _resolve_ref to turn HEAD into a SHA, once more at limit=20
purely to find the entry describing that same SHA. And list_tree's recursive
tree GET was thrown away, so fetch_files immediately fetched the identical tree
again to map path -> blob SHA. _resolve_ref now returns the entry it already
has, and list_tree returns its blob_shas map for fetch_files to take as an
optional argument. GitLab reads files by path and ignores it.

E2 — `commit: null` for a ref outside the 20 most recent. Two causes, and the
second is the one that actually bit: REF_PATTERN accepts a 7-character ref while
providers return the full 40, so the exact `==` in the scan never matched an
abbreviated SHA *even when the commit was in the window*. Fixed by prefix
comparison, plus a get_commit(ref) on the GitHub and GitLab backends for the
genuinely-outside-the-window case. Gitea and Forgejo inherit GitHub's. Still
best-effort: it is a subject line and a date, so a failed lookup renders the
preview without them rather than failing it.

E7 — the two tree readers disagreed, and each was wrong in the other's
direction. GitHub's recursive trees endpoint is not paginated and signals
overflow with truncated=true, which _blob_shas_at hard-fails on. Gitea and
Forgejo *do* page that endpoint, and inherited that single GET unchanged — so a
large backup repo returned only the first page and every category beyond it
looked absent from the commit. GiteaBackend now has its own paging
_blob_shas_at. GitLab had the mirror-image bug the review did not name: at its
50-page cap it exited through the while condition and returned success: True
with a silently partial path list. Both now fail loudly, which is what the
GitHub version was always doing.

Both halves of E7 are the same failure the module already refuses to allow: a
restore that skips categories and calls it "not present in this backup commit".

24 new or changed tests, all failing against this commit's parent.
2026-08-04 08:57:34 -04:00
jmoore-skild 945f50a1ab fix(backup): stop overwrite writing a spool's other tag key (#2656)
Four of the review's smaller items.

E6, the substantive one. tag_uid and tray_uuid are both in the overwrite setattr
loop, so a spool matched on one key got the backup's *other* key written onto it.
Neither column has a unique constraint (models/spool.py, and no unique index in
the migrations), so nothing errors — a duplicate tag simply appears, after which
_find_spool's .scalars().first() is non-deterministic and an AMS tag lookup
resolves to an arbitrary one of the two spools. The same loop could also clear a
tag the user had scanned since the backup was taken, when the backup entry held
None.

_find_spool now reports which key matched, and _guard_tag_overwrite drops a tag
column from the write when the incoming value is empty and the local row has one
(the backup predates the scan, so the local tag is the newer fact) or when
another local spool already holds it. Announced in the tally the way the archive
un-delete case already announces itself, rather than done silently — the
spoolTagKept locale key landed with the rest of the i18n block last commit.

E5. The Restore button is hidden without github:restore. All three endpoints are
gated on it server-side, so the modal 403s on its first preview; offering the
button is offering an action that cannot work. Button only — the card stays
visible, since configuring backups is a separate permission — and hasPermission
returns true with auth off, so a single-user instance is unaffected.

E3. models/github_backup.py: the trigger comment said manual/scheduled; this PR
added a third value.

E4. ha_token_from_env: recommending no change, with the reasoning recorded as a
test rather than left in a review thread. It is built only in the settings GET
response, is absent from AppSettingsUpdate, and is therefore never a Settings
row — it cannot reach a backup, so an allowlist entry would be dead code. Worse,
a name-shaped exception to a belt-and-braces denylist is a live hole: an
attacker-authored settings/app_settings.json could get a *token*-named row
written by choosing that name.

4 unit tests and 1 frontend test that fail against this commit's parent, plus 4
controls: a free tag is still written, an unchanged tag is not reported as kept,
an insert is unaffected, and the button still shows with auth disabled.
2026-08-04 08:57:34 -04:00
jmoore-skild af8d14d796 i18n(backup): make the restore notes and preview details translatable (#2656)
A German user got a translated modal with "Not present in this backup commit" in
the middle of it. Every tally note and preview caveat was a server-built English
sentence rendered verbatim.

Follows the backup.pathCheck contract already in use one card down in the same
component, deliberately rather than inventing a second convention: the server
sends a `code` plus typed `params` and carries the English along as the
fallback, and the client renders
`t(`...${code}`, { ...params, defaultValue: message })`. The defaultValue arm is
what keeps a newer backend's unfamiliar code readable instead of printing the
raw key — covered by its own test.

Shapes:
- notes: list[str] -> list[GitHubRestoreNote] {code, params, message}. Breaking,
  but the field is unreleased in this same PR.
- GitHubRestorePreviewCategory gains detail_code / detail_params; `detail` stays
  as the English fallback.
- _CategoryTally.note(code, message, **params), deduped on (code, params) rather
  than on the rendered text, so two offline printers both keep their names. The
  20-note cap is unchanged.

28 new leaves across 13 locales: 20 notes.* and 8 details.*. `noData` collapses
the four per-category "No X data in this backup" strings into one, since the
category heading already renders beside it. Counts use single-form {{count}} in
the existing "N record(s)" style rather than i18next plural suffixes — nothing in
this block uses _one/_other and the parity script has extra rules for them.
Parity holds at 5737 leaves in all 13 locales.

Deliberately out of scope, and worth saying so rather than leaving it to look
like an oversight: result.message, the commit-picker subject lines and the HTTP
error strings stay English. Those also originate in the provider backends, so
code-ifying them widens the diff well past the restore service.

spoolTagKept is added here with the rest of the locale churn but is not emitted
until the next commit, so the 13-locale change lands once.
2026-08-04 08:57:33 -04:00
jmoore-skild cfa82bfcfb fix(backup): carry the owner across, or restored archives are invisible (#2656)
Neither _collect_archives nor _restore_archives touched created_by_id, so every
restored archive row landed NULL. That column is not attribution, it is what the
access check runs on: _ensure_archive_visible (api/routes/archives.py) fails
closed on NULL — a 404 for any caller without archives:read_all — and the list
paths filter created_by_id == user.id. On a multi-user instance the tally
therefore reported archives restored while the person who owns them could
neither list nor open them.

Same shape as the deleted_at fix, and the same remedy: the collector records the
key next to deleted_at, the restore mirrors the printer_id/project_id pattern
exactly — one hoisted select(User.id), a membership test per row, an unknown id
coerced to None rather than failing the row, and one de-duplicated note. It is in
the overwrite setattr loop too, so overwrite keeps meaning "make local match the
backup". Additive on the backup side, so older backups still restore; they just
cannot know the owner.

Clearing the id is not silent-safe, so the note says what it costs: those
archives are visible only to users with archives:read_all until an admin
reassigns them.

Caveat recorded in a comment and raised in the PR, not decided here: this is the
one place the module reuses a raw backup id, against its own rule. Validating it
means a *stale* id clears rather than pointing somewhere wrong, but a live id
belonging to a different person on a different instance would still collide.
Collecting username and resolving on that would close it.

6 unit tests and 1 integration test that all fail against the parent commit,
plus 2 controls that pass either way — a backup with no created_by_id key still
restores, and a second operator still gets a 404.
2026-08-04 08:57:33 -04:00
jmoore-skild ca93d4cf44 fix(backup): never restore a toggle whose credential can't come with it (#2656)
A settings restore refuses to write anything credential-shaped, but wrote the
switches that depend on those credentials like any other key. Restoring the two
halves apart is not a partial restore, it is a downgrade.

The sharp case is Prometheus. /api/v1/metrics is on PUBLIC_API_ROUTES and its
only gate is `if token:`, so an empty or absent token means no authentication at
all. prometheus_token matches the `token` hint and is refused; prometheus_enabled
is an ordinary key and was written. On an instance that never enabled Prometheus
there is no local token row, so overwrite-*off* alone was enough to publish the
whole metrics body to anyone who could reach the port. The new integration test
shows exactly that: 200 with a full unauthenticated body before, 404 after.

Four more pairs are the same shape and break an integration rather than open one:
ldap_enabled/ldap_bind_password, mqtt_enabled/mqtt_password, ha_enabled/ha_token
(with an HA_TOKEN env arm, since get_homeassistant_settings prefers the
environment over the row), and virtual_printer_enabled/virtual_printer_access_code
— the last largely vestigial post-migration, included for consistency.

A toggle is refused only when all five hold: the payload value is truthy, the
backup carried a non-empty companion credential, that credential is denylisted,
this instance has no usable value for it, and the toggle is not already on
locally. The second condition is what keeps the rule honest — an anonymous MQTT
broker and an anonymous LDAP bind are legitimate configs that pass empty
credentials straight through, and without it both would be false positives. With
it, the rule fires only when the restore would produce a config weaker than both
the backup and the local instance. A present-but-blank prometheus_token row
counts as unusable, since that is precisely the `if token:` hole.

The rule needs the payload *and* local database state, which the old static
_count_items could not see, so preview and restore now share one classifier:
_plan_settings() runs a single SELECT over both halves of every candidate pair
before anything enters the session, and returns the three refusal buckets.
preview() takes the session the route already has. _is_skipped_setting_key is
gone rather than having its docstring corrected as asked: a name is no longer
enough to decide, so the union predicate had no caller left.

Also implements the review's third ruling — the tally counts what the preview
counted, and refusals live in the notes. Two `skipped += 1` increments are
dropped (blocked, protected) and the companion refusal adds none; the value-is-
None and overwrite-off skips stay, because they depend on the run's flags, which
the preview cannot see. restored + skipped + failed now equals the item count the
user was shown — off by three before.

Behaviour change called out for review: test_credential_keys_are_never_restored
and test_auth_settings_are_never_restored asserted skipped == 2 and 4; both are
now 0, which is the point of the ruling.

16 new unit tests plus 2 integration tests. Nine of them are controls, because
over-refusal is the real risk of this change — the anonymous-broker and
anonymous-bind guards are load-bearing, not decoration.
2026-08-04 08:57:24 -04:00
jmoore-skild 07244b6a43 fix(backup): don't count a stale selection, and say when archive links are dropped (#2656)
Two smaller restore-path fixes from the same review.

The modal's footer counted `selected` raw while the checkboxes rendered
`selected && isAvailable`. Switching commits keeps `selected` on purpose (it is
only pruned once the new preview lands), so for as long as the new commit's
preview was in flight — with the category list replaced by its spinner — the
footer still read "2 selected" over an enabled Restore button, and clicking it
restored the newly-picked commit with the previous commit's categories, none of
which the user had seen an item count for. The count and the POST body now come
from one `selectedCategories` memo gated on availability, exactly as the
checkboxes are, so both go empty until the preview lands.

Restoring Spool inventory without Print archives leaves archive_id_map empty,
so every usage -> archive link is nulled even where the archive exists locally.
It can't be resolved here (the archives payload isn't fetched for a category
that wasn't selected) and a later archives-only restore won't repair it either,
since the usage dedupe key doesn't include archive_id and those rows read as
already-present. So it gets a note naming the remedy while the user can still
redo the run with both categories ticked.

Carries the rebuilt bundle (index-C2LOlVCR.js -> index-C16HJNOV.js).
2026-08-04 08:22:58 -04:00
jmoore-skild e7495dd41b fix(backup): reconnect the MQTT relay after restoring mqtt_* settings (#2656)
The relay reads its broker config once, when configure() is called — which is
why the settings PUT handler reconfigures it after writing those rows
(api/routes/settings.py:246). The restore wrote the rows and stopped there, so
the relay stayed on the pre-restore broker until the next backend restart while
the UI showed the restored values: the one way a settings restore could look
applied without being applied.

_restore_settings now reports the keys it actually wrote, and run_restore
reconfigures the relay from the committed rows when any of them is an mqtt_ one.
Three details worth keeping:

* it runs after the commit, because configure() drops the connection and
  rebuilds it — not something to do on values a later failure could roll back;
* it is keyed on written, not merely present: a key skipped for overwrite=off
  or by the credential blocklist must not trigger a reconnect;
* mqtt_password is never restorable, so configure() gets the row already in the
  database and an unchanged broker keeps working.

A broker that refuses the new config is noted on the settings tally ("restart
Bambuddy") rather than failing the restore, matching the PUT handler's
best-effort handling of the same call.
2026-08-04 08:22:58 -04:00
jmoore-skild 3ba89c60a1 fix(backup): release the SQLite writer before the K-profile MQTT phase (#2656)
_apply ran archives and spools first, which autoflushes their INSERTs and so
opens SQLite's single write transaction, then called _restore_kprofiles —
which awaits get_kprofiles per printer per nozzle at timeout=5.0 with
max_retries=3, i.e. up to ~15 s each against a printer that ignores the
request. The commit only came afterwards, in run_restore. busy_timeout is 15 s
(core/database.py:21), so a restore covering a couple of unresponsive printers
held the writer past it and unrelated writes elsewhere in the app failed with
"database is locked".

Commit the database categories before the MQTT phase starts. The K-profile
work is not in that transaction anyway — it leaves over MQTT — so the only
thing lost is rolling those categories back when a K-profile send fails, and
that rollback was never the right behaviour: extrusion_cali_set has already
reached the printer by then, so undoing the database half would just make the
two disagree.
2026-08-04 08:22:58 -04:00
jmoore-skild 737258202b fix(backup): never restore the auth policy settings from a backup (#2656)
_collect_settings exports every Settings row minus two credential keys, so
auth_enabled / advanced_auth_enabled / local_login_enabled / setup_completed
all travel in a backup, and none of them are credential-shaped enough for
_SECRET_KEY_HINTS to catch. Writing them back was the one part of a settings
restore that changed who can reach the instance rather than how it behaves:

* auth_enabled=false — from any backup taken before auth was turned on —
  disabled authentication. core.auth caches only the enabled=True result, on
  a 30 s TTL, precisely so staleness fails closed; set_auth_enabled pairs its
  write with invalidate_auth_enabled_cache(). The restore did neither, so it
  left the stored value the open one.
* local_login_enabled=false walked straight past the #1589 refusals in
  update_settings (no enabled OIDC provider / no OIDC link on the caller),
  which exist to stop exactly that lockout.
* /github-backup/restore is gated on GITHUB_RESTORE alone, so honouring these
  keys made that permission a way to rewrite auth config without
  SETTINGS_UPDATE.

Flipping an existing row needed overwrite_existing, so the odds were lower
than the severity. Both refusals now share _is_skipped_setting_key so the
preview's item count still matches what a restore writes, and the skipped
keys get their own note pointing at the auth UI rather than being folded in
with the credential ones.
2026-08-04 08:22:58 -04:00
jmoore-skild cbe412607f fix(backup): resolve K-profile cali_idx live instead of reusing the backup's (#2656)
Restoring K-profiles addressed extrusion_cali_set at the cali_idx recorded in
the backup. If that slot no longer existed on the printer the write was
silently dropped and the restore still reported the profile restored.

Not an edge case: Bambuddy's own K-profile editor is what re-keys the slot.
On a single-nozzle printer an edit is delete-then-add, so any edit between
backup and restore reproduces it.

Found testing on an X1E. Backup held cali_idx 8151; an edit through the UI
re-keyed the profile to 4606; the restore published cali_idx 8151, the printer
ignored it, and the tally read "1 restored" while the k-value stayed put.
Resending the identical payload with cali_idx 4606 applied, isolating the
stale index as the cause.

Fix mirrors the natural-key matching spools and archives already use, which
the module docstring already promised but scoped to spool.id and
print_archives.id. Before writing, read the live profiles for the nozzle and
match on filament_id + setting_id, falling back to filament_id + name, then to
the sole candidate for that filament. Send that profile's current cali_idx;
where nothing matches send -1 so the printer adds a new profile instead of
addressing a dead slot, and say so in the tally. A failed read degrades to
adding rather than aborting.

Also corrects the tally note. The printer does acknowledge extrusion_cali_set
-- it answers with a result/reason pair -- so "published without
acknowledgement" was false. It reports "fail" on writes that land, though, so
the note now says the acknowledgement is unreliable rather than absent.
Consuming result is left to a follow-up.

Re-verified on the same X1E: perturbed to k=0.061, restored from the commit
carrying the stale slot, payload went out with cali_idx 4606 and the printer
read back 0.027.
2026-08-04 08:22:57 -04:00
jmoore-skild 6a239314dc feat(backup): restore selected categories from a Git backup commit (#2656)
The Git backup feature was push-only: there was no equivalent of the local
backup's Restore button, so recovering meant hand-downloading JSON files from
the repository. This adds the read side.

Providers gain list_commits / list_tree / fetch_files on the GitProviderBackend
ABC. GitHub implements them against the Git Data API and Gitea/Forgejo inherit
that unchanged; GitLab overrides for its own REST shape, including tree
pagination and subgroup path encoding. fetch_files is batched so the path ->
blob SHA lookup happens once per restore rather than once per file, and uses the
blobs API rather than contents because contents silently inlines only the first
1 MB.

The new GitHubRestoreService resolves HEAD to a concrete SHA up front, so a
preview and the restore that follows act on the same commit even if a scheduled
backup lands in between. Categories are applied archives -> spools -> settings
-> kprofiles: archives first because spool usage history references archive_id,
K-profiles last because they leave the database and publish over MQTT.

Restores never reuse the backup's primary keys. spool.id and print_archives.id
are bare autoincrement columns, so ids from an old backup very likely belong to
unrelated rows today; rows are matched on natural keys (tag_uid, then
tray_uuid, then a descriptive composite for spools; content_hash or filename
plus started_at for archives), inserted without an explicit id, and an
old_id -> new_id map rewrites the foreign keys in spool usage history.
created_at is carried across on insert so restoring the same backup twice
matches instead of duplicating. Dangling printer/project links are cleared and
reported rather than failing the row.

Settings restore re-applies the collector's credential denylist on the read
side, plus a pattern guard, because a backup taken before that denylist existed
can still contain secrets. Restored archives are metadata-only: the 3MF and
thumbnail bytes are not in a Git backup and print_archives.file_path is NOT
NULL, so inserted rows get an empty path and the UI says so.

Backup and restore take a mutex against each other; both write the same tables
and talk to the same printers. Restores are logged as GitHubBackupLog rows with
trigger="restore", which needs no migration and surfaces them in the existing
History card.

Cloud profiles are deliberately not a restore category. The collector never
actually writes cloud_profiles/*.json - it reads a "setting" list key the Bambu
Cloud API does not return - and the preset list it would write carries no
setting payload. Filed separately.

Permission github:restore already existed and is granted to Administrators, so
no permission changes were needed.

Tests: 125 new backend tests (provider reads across all four providers, the
per-category appliers, the API endpoints) and 13 frontend tests. Full suites
pass with no regressions; the 35 backend failures on Windows are byte-identical
with and without this branch.
2026-08-04 08:22:57 -04:00
maziggy 33ab5f1ead Add temperatures to the streaming overlay and a URL builder (#1422)
The overlay at /overlay/{printer} draws live print data over a
full-screen camera view for OBS, a wall display or any browser source.
It has been tunable since it shipped -- which fields, what size, what
frame rate -- but only through query parameters documented in the wiki,
and temperatures were not among the fields on offer. The request asked
for temperatures first and for the field set to be selectable in the web
UI; this addresses both.

Nozzle, bed and chamber readings join the list. The target is drawn only
while the heater is still climbing, so a settled hotend reads "220°C"
for the rest of the print instead of the noisier "220 / 220°C" -- 219.6
against a target of 220 rounds to the same number, and repeating it says
nothing. Both nozzles appear on a dual-nozzle machine. They are drawn
whether or not a print is running, because a preheating printer is
exactly when they are worth watching, and each reading appears only when
the printer genuinely reports one: chamber temperature stays absent on
P1 and A1 models, which publish a chamber_temper with no sensor behind
it, so the overlay never puts a measurement on screen that does not
exist. Labels reuse the heater chart's strings rather than inventing a
second vocabulary for the same three things.

The feed sends an allow-list rather than the temperatures dict. That
dict doubles as the MQTT client's working memory -- derived heater flags
and private target-set timestamps live alongside the readings -- and an
overlay token is a narrower grant than a login, so it gets exactly what
the overlay draws and does not pick up fields as the dict grows. The
same chamber-sensor gate the full status payload already applies is
applied here. The integration test that asserts the payload's exact key
set, which exists to catch that surface widening silently, is updated
deliberately.

Temperatures are not in the default field set, so an overlay URL already
pasted into a scene renders identically after upgrading.

Settings -> API Keys -> Streaming Overlay now builds the URL: printer,
field checkboxes, size, frame rate, camera toggle, an optional token,
and a copy button. It persists nothing and calls nothing new -- the URL
is the configuration, which keeps a scene reproducible by copy-paste and
lets two displays show different fields off one token. Fields are
emitted in the overlay's own top-to-bottom order rather than click
order, and parameters left at their default are omitted, so the same
selection always produces the same URL. The preview alongside it stays
off until asked for: an always-live iframe would hold a subscriber on
the printer's single camera connection for as long as the settings tab
stayed open.

The preview needed one narrow security-header change. Every SPA route
sent frame-ancestors 'none', which is stricter than the SAMEORIGIN in
X-Frame-Options beside it and refuses even a same-origin frame, so the
preview showed Firefox's "another site has embedded it" page instead of
the overlay. The overlay path now sends 'self', mirroring /gcode-viewer,
which admits a framer only on this origin -- Bambuddy's own UI. Every
other path keeps 'none', and embedding the overlay from another host
still requires TRUSTED_FRAME_ORIGINS.
2026-08-04 12:38:36 +02:00
maziggy 48c231d8ce Stop a drying cycle reporting itself finished a minute in (#2759)
Starting the dryer on an AMS 2 Pro holding two PETG and two PLA spools
and picking PLA showed "PLA @ 45°C" for about a minute and then switched
to "PETG @ 65°C" for the remaining twelve hours.

Bambu never echoes back which filament or temperature a cycle is
running, so the badge reads the target cached when the command went out,
and that cache had been dropped. Between accepting the command and
settling its countdown the firmware publishes one update with the
remaining time at zero while the unit is still in its Checking phase --
the reporter's log has 720, then 0, then 719, and four seconds later the
same unit's info hex decodes to dry_status 2, Drying. The falling-edge
detector read that zero as the cycle ending. Losing the cached target
left the badge to guess from the first loaded slot, which was PETG, and
its RFID-recommended 65°C. The same false ending fired
on_drying_complete, so anyone with smart-plug auto-off-after-drying
switched on had power scheduled to cut one minute into a twelve-hour
dry; the reporter had it off, which is the only reason this reads as a
cosmetic bug.

A remaining time of zero now ends a cycle only when the unit is not also
reporting an active phase. dry_status comes from the same info hex
already parsed a few lines above, so this costs nothing to check.
Stopping and Error are deliberately not treated as active -- those
should end it -- and a unit that reports no phase at all still ends its
cycles, so the gate can only ever suppress on positive evidence that the
cycle is live. A suppressed edge leaves the remembered dry_time alone,
exactly as the #1462 absent-value skip does, so the push that really
ends the cycle still sees a non-zero previous.

The fallback guess is tightened to match. On a mixed unit the first tray
is evidence of nothing, and naming a temperature the cycle is not using
is worse than naming none, so it now answers only when every loaded
spool is the same filament and otherwise leaves the badge showing the
countdown alone. Both the websocket and REST status builders carried
their own copy of that loop; they now share one helper, which also takes
the temperature from the first slot that carries an RFID one rather than
giving up when slot 1 holds a third-party spool.
2026-08-04 11:47:17 +02:00
maziggy 71a06f3638 Add batch orders with a quantity per plate (#342)
Printing a multi-plate file in different quantities per plate meant
queueing each plate separately and tracking the counts by hand: one
shared Quantity field cannot say "plate 1 once, plate 2 twice, plate 3
three times". Each selected plate now carries its own quantity, and the
submission becomes an order on a new Batches tab.

The point is the distinction the old flat batch could not express.
print_batch_plates stores how many runs of each plate were wanted,
separately from what was queued, so a run that fails, is cancelled or is
skipped does not satisfy a target -- the order goes on saying it owes a
print instead of quietly under-delivering. Queue remaining re-queues
exactly what is missing, for the whole order or one plate, by cloning
the most recent item for that plate: that inherits the printer or model
target, AMS mapping, filament overrides and print options along with the
validation they already passed, rather than re-serialising twenty fields
through a template that would drift from the model the first time
someone adds a column. Clones append to the end of the relevant
printer's queue and take the same advisory lock the add-to-queue route
does; positions are per-printer sequences, not global.

Cost is measured, not estimated. print_log_entries gains queue_item_id,
set where the queue item is already in scope, so each run's material and
energy are attributed through the item that produced them -- an
unrelated reprint of the same archive never lands in an order's total,
and a multi-plate order gets each plate's own cost rather than the whole
file's via the plate-scoped estimate from #2614. Before any run has
completed there is no honest figure, so cost reads as unknown instead of
a fabricated 0.00.

The Batches tab wires up GET /queue/batches, which has been unreferenced
since the batch MVP shipped, along with six locale keys that were
translated and never used. It is a separate tab because an order
outlives the queue that produced it: once its runs finish they leave the
active queue, so Queue and History each hold half the picture.

completed was not a reachable status before now, so every batch created
since April is still marked active however long ago its last print
finished -- 73 of them on the development install. A startup pass closes
out the finished ones: those whose runs all completed become completed,
and groupings whose items were all cancelled become cancelled, which is
what they are. Not applied to orders, which state their intent
independently of their runs and still owe the work. Only batches with
nothing queued or printing are considered, and repeating the pass also
catches an order whose last run landed while the process was down.
Batches with neither items nor targets are no longer listed at all --
empty shells left when a grouping's items went with their source
archive.

Dispatch applies the same source-file gates as POST /queue/. It creates
queue items, so without them it would be a weaker door to the same
outcome; the archive and library-file checks move into shared helpers
so a third route cannot drift from them.
2026-08-04 11:11:36 +02:00
maziggy ebc73e46ba Say when AMS drying was running and a print never started (#2758)
Dispatching to an X2D with two AMS units mid-drying failed silently: the
file uploaded, the printer accepted it and stayed idle. The watchdog waits
for an active state or HMS_MQTT_VERIFY_FAILED, and a drying refusal is
neither, so it timed out, re-uploaded the whole 3MF twice more, and closed
with advice about the printer screen and the SD card. Studio, asked
directly, said it could not start the job because of the drying.

Latch the AMS dry_time telemetry across both watchdog phases and name the
units in the give-up message, plus an INFO log on every failed window so
the correlation reaches a support bundle from the first attempt.

Detection only, no gate. These models support drying CONTINUING through a
print (supports_drying_while_printing covers X2D from 01.01.00.00), so
drying is not incompatible with printing and stopping it before every
dispatch would tear down cycles the hardware is happy to run. One of the
two units was also drying without its external PSU, which would make this a
power budget problem at start-of-print calibration rather than a drying one
-- dry_sf_reason 1/8 exist for exactly that. The message names both
possibilities rather than asserting one.

Also correct _sync_drying_state's docstring, which claimed to adopt drying
it did not start; it only prunes. Behaviour unchanged -- populating it would
let the scheduler stop a cycle the user started by hand.
2026-08-04 08:49:49 +02:00