Procházet zdrojové kódy

Merge pull request #2840 from maziggy/1.2.5.3

**Bambuddy 1.2.5.3**

**What this is**

A feature-and-fix release on top of 1.2.5.2, with four things carrying most of it: the slice dialog gains OrcaSlicer's full process-parameter set, the G-code and model previews are rebuilt on the slicer's own renderer, the H2C's six-hotend nozzle rack can finally be aimed rather than guessed at, and selected categories can be restored from a Git backup commit. Around it are 39 fixes, a heavy run of them on AMS drying, slicing and the queue. Five of the features come from outside contributors. No breaking changes. Several table and column additions are applied automatically on both SQLite and PostgreSQL.

If you are coming from 1.2.5 or earlier, read the 1.2.5 release notes first — all of its upgrade callouts apply to you as well.

**Docker**

docker compose pull
docker compose up -d

**Native install — recommended path**

sudo BRANCH=main /opt/bambuddy/install/update.sh

**Native install — manual path**

sudo systemctl stop bambuddy
cd /opt/bambuddy
sudo -u bambuddy git fetch --prune --tags --force origin
sudo -u bambuddy git checkout main
sudo -u bambuddy git reset --hard origin/main
sudo /opt/bambuddy/venv/bin/pip install -r requirements.txt
cd frontend && sudo npm i
sudo systemctl start bambuddy

**Windows install**

Download bambuddy-1.2.5.3-windows-x64-setup.exe from this release page (or the unversioned bambuddy-windows-x64-setup.exe alias). Existing Windows installs upgrade in place via the in-app Install Update flow.

**New**

- Billing and cost centres, with per-print charging and budgets (#1448, contributor @behrinml) — Bambuddy could tell you what a print cost but could not hold anyone to it. There is now a finance layer behind the print flow: cost centres with budgets, per-user wallets, and a transaction for every print. A cost centre can be picked in the print dialog, travels with the queue item and the archive, and is reserved against before the job is dispatched rather than after it finishes, so a print that would take a budget past its limit does not start. Charges settle on real filament usage at completion, and a print that aborts part-way is charged for the part that ran instead of being written off or billed in full. Every user gets a personal cost centre and wallet on first sign-in, including the first sign-in through LDAP, so a directory-backed install does not need them created by hand. A monthly reset day and timezone decide when budgets roll over. The whole feature is behind a billing toggle and is off by default, and an optional printer kill switch stops dispatch entirely once a budget is exhausted. Cost-centre management has its own permissions rather than riding on the settings ones, so a farm can let someone spend against a budget without letting them change it. Ships with a Finance page, migrations for both SQLite and PostgreSQL, and translations in all locales.

- One queue item, several printer models — whichever frees up first (#671, reporter @brainomite; also delivers most of #2570, reporter @NeighborGeek) — With an H2S and an H2C, a job you don't care which machine runs still had to be queued twice: the two printers need different slices, a queue item held exactly one file, and "any H2S" and "any H2C" were separate jobs competing for the same plastic. Whichever started first, you deleted the other by hand. Select both sliced files in the File Manager and press **Print** and you now get **one** queue item carrying both — the scheduler walks them in the order you arranged and takes the first whose model has an idle printer. The many-to-many never leaves the scheduler's selection loop: the moment a candidate wins, its file, plate and nozzle mapping are folded onto the queue row, so the upload, archive creation, print history and reprint all see an ordinary single-file job and behave exactly as they always have. Order is yours to set, because "both are free right now" has to resolve the same way every time rather than following whichever match the matcher happened to see first. Candidates are otherwise tried least-attempted first, so a printer that accepts the file and never starts hands the job to the other machine on the next lap instead of spending the item's whole retry budget on the one - **Billing and cost centres, with per-print charging and budgets (#1448, contributor @behrinml)** — Bambuddy could tell you what a print cost but could not hold anyone to it. There is now a finance layer behind the print flow: cost centres with budgets, per-user wallets, and a transaction for every print. A cost centre can be picked in the print dialog, travels with the queue item and the archive, and is reserved against before the job is dispatched rather than after it finishes, so a print that would take a budget past its limit does not start. Charges settle on real filament usage at completion, and a print that aborts part-way is charged for the part that ran instead of being written off or billed in full. Every user gets a personal cost centre and wallet on first sign-in, including the first sign-in through LDAP, so a directory-backed install does not need them created by hand. A monthly reset day and timezone decide when budgets roll over. The whole feature is behind a billing toggle and is off by default, and an optional printer kill switch stops dispatch entirely once a budget is exhausted. Cost-centre management has its own permissions rather than riding on the settings ones, so a farm can let someone spend against a budget without letting them change it. Ships with a Finance page, migrations for both SQLite and PostgreSQL, and translations in all locales.

- One queue item, several printer models — whichever frees up first (#671, reporter @brainomite; also delivers most of #2570, reporter @NeighborGeek) — With an H2S and an H2C, a job you don't care which machine runs still had to be queued twice: the two printers need different slices, a queue item held exactly one file, and "any H2S" and "any H2C" were separate jobs competing for the same plastic. Whichever started first, you deleted the other by hand. Select both sliced files in the File Manager and press **Print** and you now get **one** queue item carrying both — the scheduler walks them in the order you arranged and takes the first whose model has an idle printer. The many-to-many never leaves the scheduler's selection loop: the moment a candidate wins, its file, plate and nozzle mapping are folded onto the queue row, so the upload, archive creation, print history and reprint all see an ordinary single-file job and behave exactly as they always have. Order is yours to set, because "both are free right now" has to resolve the same way every time rather than following whichever match the matcher happened to see first. Candidates are otherwise tried least-attempted first, so a printer that accepts the file and never starts hands the job to the other machine on the next lap instead of spending the item's whole retry budget on the one that is wedged. The set is validated as a set: one file per printer model (two slices for the same machine are not alternatives, and picking between them arbitrarily would look like a bug the first time it chose your draft profile), every file gated against the model it is offered as, and at least one model that actually has a printer — grouping the H2C slice before the H2C arrives is fine, queueing a job nothing can ever run is not. A cross-model item deliberately holds no file of its own, so deleting one alternative leaves the job and its sibling intact; deleting or trashing every candidate holds it with an explanation instead of failing deep in the upload. Filament overrides offer everything loaded across **all** the candidate models rather than just the first — a spool loaded on only one of them is still a legitimate choice, it simply narrows which candidates can match — while AMS slot mapping is absent exactly as it is on an ordinary "Any [model]" job, because no printer has been picked yet and the scheduler derives the mapping against whichever one it takes. In the queue the job reads **Any H2D / X1C**, naming every model it is waiting on rather than filing itself under one it may never run on, and its waiting reason is given per model (`H2D: Busy: H2D-1; X1C: No matching material/color`), collapsing to a plain busy message — and no notification — when every model is merely printing. The alternatives are fixed once queued: the schedule, quantity and print options stay editable, but assigning a specific printer or narrowing to one model is refused by both the dialog and the API, since an item holding alternatives *and* a printer would dispatch down the fixed-printer path with no file to send. Cancel and re-queue to change the set. Files can also be grouped permanently with **Group as versions**, after which printing any one of them offers the others without re-selecting — this is the grouping and the print-time file matching asked for in #2570, minus its nested File Manager listing. An existing library arrives with its groups already built, from slice provenance Bambuddy has been recording since the Slice button shipped and had never read back. Translated in all locales; wiki updated. Covered by backend and frontend tests.

- Batch orders: a quantity per plate, and an order that knows what it still owes (#342, reporter @cimdDev) — Printing a multi-plate file in different quantities per plate meant queueing each plate separately and tracking the counts yourself, because one shared **Quantity** field cannot say "plate 1 once, plate 2 twice, plate 3 three times". Each selected plate of a multi-plate file now carries its own quantity, and the submission becomes a **batch order** on a new **Batches** tab of the Print Queue page. What that buys is the distinction the old batch could not express: the order records how many runs of each plate were *wanted*, separately from what was queued. A run that fails, is cancelled or is skipped does not satisfy a target, so the order goes on saying it owes a print instead of quietly under-delivering — and a **Queue remaining** action re-queues exactly what is missing, for the whole order or one plate. Those new items are copied from the most recent run of that plate, so they inherit the printer or model target, AMS mapping, filament overrides and print options already chosen, and they are appended to the end of the relevant printer's queue rather than jumping ahead of work already lined up. Orders show progress against target, per-plate breakdown, and cost. Cost is measured rather than estimated: each finished run's material and energy are attributed through the queue item that produced them, so an unrelated reprint of the same file never lands in an order's total, and a multi-plate order gets each plate's own cost rather than the whole file's. Before any run has completed there is no honest figure, so cost reads as unknown instead of a fabricated `0.00`. An order becomes **completed** the moment its last run lands rather than whenever someone next opens the page, and raising a target on a finished order reopens it. Targets stay editable while the order runs, since production requirements change mid-job. The default flow is unchanged — creating an order still queues all of it immediately, and a single-plate file still has one Quantity field. Batches created before this release keep working and are labelled **Grouping only**: they only ever knew what was queued, not what was wanted, so they report progress but have nothing to dispatch. They also get closed out on the first start after upgrading — `completed` was not a reachable status before now, so every batch created since grouping shipped is still marked active however long ago its last print finished, and without that pass the new tab would open on months of accumulated history. Only batches with nothing queued or printing are touched: those whose runs all completed become completed, and groupings whose items were all cancelled become cancelled, which is what they are — calling them completed would claim output that never happened. Batches with neither queue items nor targets are no longer listed at all; those are empty shells left behind when a grouping's items were deleted with their source archive. Translated in all locales; wiki upd
ated. Covered by backend and frontend tests.

- Nest projects under a master project and roll their figures up (#1264) — Projects were flat. The `parent_id` column and the sub-project list already existed but nothing outside the API could set a parent, and a master project's statistics only ever covered its own prints. The project dialog now has a parent picker, and a project with sub-projects gets a second card covering the whole tree: jobs, parts, time, filament, cost, and progress against every target in the tree added together. That card is deliberately separate from the project's own stats, which keep their existing meaning — widening them would have restated the figures of anyone who had already nested projects over the API. Each listed sub-project carries its own branch's roll-up, so the rows add up to the card above them. On the Projects page a sub-project is drawn inside its parent's group rather than as another card in the grid, because two cards columns apart cannot show that they belong together whatever the caption says. Translated in all locales; wiki updated.

- Keep the chamber warm between prints and skip a soak that is not needed (#2727, contributor @ticfinack) — Back-to-back prints in chamber-heated materials — ASA, ABS, PA, PC — each paid a full heat soak from cold, even when the print that just finished had left the chamber at temperature. Two changes remove that cost. While a printer sits in FINISH waiting for plate-clear and the next queued item needs chamber heat, the bed is held hot so the chamber does not cool during the bed-clearing window. The bed is the chamber's heating element here rather than a print surface, so the hold runs at the new **Keep-warm bed temperature** (90 °C by default, which also satisfies the aftermarket chamber heaters that trigger off a bed threshold) and rises to the item's own bed temperature when that is higher. It is gated on the keep-warm setting, on plate-clear being required, and on the next item actually needing the heat, and it is capped by a maximum duration so a queue that stalls does not leave a bed hot indefinitely. A follow-up closed the two dispatch exits that could drop the hold without releasing it — a claim failure returns before the rollback opens, and a vanished row left the printer id unset, which the rollback guards on — either of which left a bed hot with nothing tracking it, reachable whenever a cancel or delete landed between selection and the claim.
- Restore selected categories from a Git backup commit — Bambuddy has pushed backups to GitHub, GitLab, Gitea and Forgejo for a while; now it can read one back. Pick a commit, preview what it holds, choose which categories to restore (#2656, contributor @jmoore-skild).
- Edit the full print-parameter set from the slice dialog — the dialog now carries OrcaSlicer's own process tree, with its pages, groups, tooltips and ranges, and evaluates the slicer's own enable/disable rules. Slicing no longer means taking a preset exactly as it comes.
- A new G-code and model preview — the vendored PrettyGCode iframe is gone, replaced by libvgcode, the renderer OrcaSlicer draws its own preview with. Real occlusion instead of screen-space lines, and it is themed and translated like the rest of the app. The model preview was rebuilt alongside it, with proper framing and lighting.
- Choose which rack nozzle each filament prints from on an H2C (#1784) — the Vortek rack holds six hotends and the choice is not recorded in the 3MF, so plates went out with no assignment and the printer picked for itself. Every rack-bound filament now has a position picker showing all six and the nozzle each holds.
- Home Assistant sensors on the printer card, with an optional print interlock (#1148, reporter @bsaunder; #448, reporter @baudneo) — surface HA entities on the card, and optionally block a print from starting when one of them says not to.
- The Print Log shows how much filament a run used, and lets you choose its columns (#2636, reporter @ajbastien).
- Auto-orient and auto-arrange when slicing server-side (#2548, reporter @ceokingcobra).
- Open a File Manager model in your desktop slicer, and pick which one from the 3D preview (#2725, contributor @pascalheidmann).
- Server-side slicing on an ARM64 host (#1900, contributor Felix Reissmann) — an override pins the sidecar to amd64 and runs it under emulation, with the binfmt requirement and the three-to-six-times slowdown stated up front. A separate x86_64 machine is still the recommendation.
- Temperatures on the streaming overlay, and a builder for its URL (#1422, reporter @SMAW).
- Open a multi-plate sliced file on the plate you asked for — the viewer gains a plate switcher and keeps the choice in its URL, and filament colours follow it instead of always coming from the first plate.
- Show the plug that actually powers the printer in the card's Power row (#2830) — which plug filled that row was previously decided by nothing at all, so it could land on an enclosure fan and offer to switch the printer off by cutting it.
- The Printers page remembers its status and location filters (#2833) — the only two preferences on that page that were not persisted.
- The external spool can be hidden from the printer card (#1782, reporter @Arn0uDz).
- Uploaded archives can be named after the filename you sent (#2610, contributor @Person2099).
- The chamber temperature limit is raised from 60 to 65 °C (reported on Discord).
- The Spool Inventory can be sorted by colour rather than by colour name (#2729, reporter @macwhiz).
- API keys can read and run slicer pipelines (#1425) — every pipeline endpoint answered 403 to a key whatever scopes it carried. Running a pipeline requires the queue and library-manage flags together, and a 403 now names every flag the key is short of.
- API clients can resolve user ids to names (#1894) — archives, the queue and statistics report ownership as a numeric id, and nothing let a key discover whose id was whose without an admin listing.
- Forgejo tokens scoped to a single repository are accepted (#2775) — a repository-scoped v15 token was rejected for failing a user lookup it does not need to pass.
- The MQTT debug log records the commands sent to a printer, not only what it reports back.
- Queue items created from the Library's bulk Add to queue and through the webhook API now record who created them, so own-work permissions can see them.

**Fixes**

**H2C and multi-nozzle:**

- An H2C levelled on one hotend and printed with another, several millimetres above the plate (#2800). A print command names the rack nozzle by physical position rather than by extruder index, and Bambuddy only ever had that position for jobs arriving through the Virtual Printer — everything else omitted the field and let the firmware choose. Two hardware-derived values were then corrected by the reporter's own A/B on real hardware, and the fixed/rack carriage assignment turned out to be inverted.
- An H2C refused a multi-colour print outright with HMS 0500-4047, a hotend mismatch: on a rack machine the slicer writes a filament group per nozzle rather than per carriage, so a three-group plate lost a filament against a two-entry map.
- The H2C nozzle rack card sizes itself to its contents instead of claiming several hundred pixels and leaving them empty, numbers its slots 1 to 6, and scales its chips with the card size.

**AMS, drying and filament:**

- AMS drying was torn down and restarted once per scheduler tick while a plate sat unacknowledged — about 2000 state changes over ten days, with no cycle ever running long enough to remove moisture, and hand-started cycles on other units of the same printer torn down with them (#2801).
- Auto-drying re-armed into a threshold it could never reach (#2770) — an AMS reads a higher humidity warm than cold, so the reading at the moment a cycle ended always armed the next one. Five twelve-hour cycles inside four hours.
- A drying cycle the printer abandons now says so, and says what the printer reported (#2770, reporter @tchavei).
- A drying cycle no longer reports itself finished a minute after it starts (#2759).
- The drying badge invented a temperature on a uniformly loaded AMS, showing the spools' RFID recommendation rather than the temperature that was picked (#2759 follow-up).
- The drying popover no longer starts a cycle under a material you did not pick (#2774).
- The nearest filament colour is picked instead of the first eligible one in tray order, and the ranking is perceptual — RGB distance overweights blue badly enough to invert the answer (#2804, #2823, contributor @grolmus). Filament type matching also agrees between the interface and the scheduler now.
- Spoolman no longer charges a Bambu Studio print to the wrong spool (#2768).
- "Any X2D" works on a printer that feeds from external spools instead of an AMS (#2771, reporter @Nick-C130).
- AMS Filament Backup no longer charges a whole print to the substitute spool — everything needed to split the filament across the trays it actually came from lived only in memory, so a print that outlived a restart lost it.
- The print dialog pools AMS Filament Backup spools in its filament check, instead of refusing a job against one slot while an identical full spool sat in the next one.
- A refused AMS filament setting now says so in the log (#2756, reporter @Jostxxl).
- Configuring an AMS slot shows up on the printer card straight away, without a page reload.

**Queue and dispatch:**

- A completion for one print closed another print's queue item, marking it completed while the printer was still working and stranding the rest of its batch (#2829). The check that fixes it also had to learn that the printer rewrites the name it echoes back, which had left queues stopped until someone cancelled by hand.
- Deleting a library file destroyed the jobs queued against it — silently on PostgreSQL, and as "Library file not found" days later on SQLite (#2819).
- A library-backed job was dispatched onto a spool that could not finish it: 20.5 g needed, 9 g loaded, no deficit reported (#2779). Slicer pipeline jobs and everything from the Library's bulk add were affected.
- A job queued to a printer class never powered a printer on, while the same file pinned to a specific printer did (#2786).
- A print that never starts now says AMS drying was running, instead of blaming the SD card (#2758).

**Slicing and previews:**

- A slice failed on a model whose name contains a slash — a MakerWorld title arrives with its punctuation and was used verbatim as a folder name (#2832).
- A slice of a file on a network share was written to managed storage instead, showing up in the right folder in the interface and never reaching the share (#2810).
- A 3MF no longer switches off supports its process preset turned on (#2820) — the carry that lets a project's support configuration survive was running in both directions.
- An oversized model reads as an oversized model, not a slicer crash (#2802). The advice to update the sidecar was wrong too: it named a bare compose pull, which skips the profile-gated sidecar silently.
- A preview slice no longer gives up on custom G-code the sidecar cannot parse — the silent fallback to guessing from painted faces was dropping a whole filament slot.
- Bundled presets resolved their start G-code to a generic block: all 56 instantiable BBL machine presets, producing a print that heats the bed, moves the toolhead and extrudes nothing.
- The process-settings panel shows the preset's own values instead of the compiled-in defaults, and names which of four causes applied when it cannot read them.
- Server-side slicing is no longer offered for STEP files, which neither slicer can load from its command line. Open in Slicer still hands them to the desktop application.

**Printers, archives and connection:**

- Archives arrived empty from printers whose file service could not answer (#2780). Two faults: H2-series and P2S firmware can keep the sliced file on internal storage, which port 990 cannot reach — the print command says which, and we discarded it and swept anyway, around 110 doomed connections per print. And a printer whose FTPS handshake wedges now gets a five-minute cool-off instead of being retried hundreds of times a minute; one reporter's log carried 1813 identical failures, another's 3511.
- Photos and filament accounting on archives that arrive without a 3MF, which on an H2S is any job started from the printer's own library (#1820). Photos were written in one place and looked for in another, and the fallback that stands in for a missing 3MF could charge nothing without a word.
- The printer card thumbnail is back after navigating away and returning (#2826) — a cache hit raced the mount effect, which is why it reproduced every time for the reporter and never here.
- Live updates stopped arriving while the Bambuddy tab was in the background (#2754, reporter @mic4rd).
- A print stage Bambuddy cannot name is now logged at INFO, once per stage number per session, with the context needed to name it afterwards.

**Interface:**

- Interactive controls show a pointer cursor again (#2791) — Tailwind v4 dropped the base rule and only 15 of 934 buttons had it written by hand.
- The Spool Inventory header no longer scrolls the whole page sideways on a phone (#2813).
- The Virtual Printer card header wraps instead of painting outside its border (#2808).
- A refused frame no longer leaves the browser's own error page inside Bambuddy's layout, and says which header blocked it (#2787).
- Form controls follow the page's colour scheme — steppers, calendar buttons, dropdowns and scrollbars were drawn light on every theme.
- The L and XL printer cards scale their text and icons, not just their width (#1848, reporter @misterff1).
- The bug-report button no longer covers the controls in the bottom-right corner (#2750, reporter @goodjaltman).
- Error and warning toasts stay up twice as long.
- The Print Log is reachable again once you have no archives, and its cost and energy figures reach the browser at all.
- The Docker update command is copyable, and knows where your compose file lives (#2664, reporter @pchulpjoost).
- The Slicer Bundles notice is gone from Settings — bundle import was withdrawn in 0.2.5 and the panel had been sitting there since, unactionable.

**Login, deployment and integrations:**

- LDAP login works again on directories that define no POSIX group class (#2769, reporter @peterskotte).
- A hand-written systemd service left the Virtual Printer unable to start, with nothing obvious to blame (#2549, reporter @Ru3ck3).
- Bambu Cloud's anti-robot challenge is explained instead of repeated back as a bare error with nothing to click (#2790).
- Home Assistant notifications carry nested data through unchanged (#1441).

**Security (dependencies)**

- Cleared every remaining npm audit and pip-audit finding. react-router and react-router-dom move to 7.18.2, which retires the documented CSRF exception in the CI audit gate — upstream backported the fix, so the exemption lapsed on its own and the allowlist is now empty. dompurify moves to 3.4.13 (shipped, but on a path this app never reaches: no hooks registered, in-place mode unused). js-yaml and nanoid are overridden, both development-only via eslint and postcss.
- Patched two build-time frontend dependencies flagged by npm audit (GHSA-r28c-9q8g-f849, GHSA-mh99-v99m-4gvg, GHSA-rgw5-rvv9-x895).

---
**Sponsors**

Bambuddy is sustainable thanks to people who put their money where their use is. If this release saved you time or kept your farm running, the project runs on recurring contributions — there's no paid tier, no telemetry, no upsell, just sustainable maintenance.

- GitHub Sponsors (recurring, 5 tiers from $5/mo to $300/mo) — https://github.com/sponsors/maziggy
- Ko-fi (one-time or recurring) — https://ko-fi.com/maziggy
MartinNYHC před 3 týdny
rodič
revize
caea50f2ca
100 změnil soubory, kde provedl 15176 přidání a 930 odebrání
  1. 4 1
      .gitignore
  2. 3 4
      .pre-commit-config.yaml
  3. 4 0
      CHANGELOG.md
  4. 7 0
      CONTRIBUTING.md
  5. 0 9
      Dockerfile
  6. 0 5
      Dockerfile.test
  7. 113 45
      backend/app/api/routes/archives.py
  8. 50 12
      backend/app/api/routes/auth.py
  9. 2 0
      backend/app/api/routes/cloud.py
  10. 1044 0
      backend/app/api/routes/finance.py
  11. 123 1
      backend/app/api/routes/github_backup.py
  12. 11 0
      backend/app/api/routes/groups.py
  13. 239 0
      backend/app/api/routes/ha_sensors.py
  14. 413 69
      backend/app/api/routes/library.py
  15. 425 0
      backend/app/api/routes/library_variants.py
  16. 1 0
      backend/app/api/routes/notification_templates.py
  17. 4 0
      backend/app/api/routes/notifications.py
  18. 28 5
      backend/app/api/routes/pipeline_runs.py
  19. 57 47
      backend/app/api/routes/print_log.py
  20. 729 75
      backend/app/api/routes/print_queue.py
  21. 69 18
      backend/app/api/routes/printers.py
  22. 295 106
      backend/app/api/routes/projects.py
  23. 61 18
      backend/app/api/routes/settings.py
  24. 70 5
      backend/app/api/routes/slicer_presets.py
  25. 103 19
      backend/app/api/routes/smart_plugs.py
  26. 83 0
      backend/app/api/routes/updates.py
  27. 39 1
      backend/app/api/routes/users.py
  28. 13 7
      backend/app/api/routes/webhook.py
  29. 211 27
      backend/app/core/auth.py
  30. 1 1
      backend/app/core/config.py
  31. 656 8
      backend/app/core/database.py
  32. 20 0
      backend/app/core/permissions.py
  33. 662 68
      backend/app/main.py
  34. 6 2
      backend/app/models/__init__.py
  35. 57 0
      backend/app/models/active_print_session.py
  36. 11 0
      backend/app/models/active_print_spoolman.py
  37. 9 0
      backend/app/models/archive.py
  38. 166 0
      backend/app/models/finance.py
  39. 1 1
      backend/app/models/github_backup.py
  40. 53 0
      backend/app/models/library.py
  41. 7 0
      backend/app/models/notification.py
  42. 22 0
      backend/app/models/notification_template.py
  43. 58 2
      backend/app/models/print_batch.py
  44. 7 0
      backend/app/models/print_log.py
  45. 98 1
      backend/app/models/print_queue.py
  46. 2 0
      backend/app/models/printer.py
  47. 72 0
      backend/app/models/printer_ha_sensor.py
  48. 10 2
      backend/app/schemas/archive.py
  49. 16 0
      backend/app/schemas/auth.py
  50. 6 0
      backend/app/schemas/cloud.py
  51. 118 0
      backend/app/schemas/finance.py
  52. 123 0
      backend/app/schemas/github_backup.py
  53. 62 0
      backend/app/schemas/library.py
  54. 14 0
      backend/app/schemas/notification.py
  55. 38 0
      backend/app/schemas/notification_template.py
  56. 9 1
      backend/app/schemas/print_log.py
  57. 155 3
      backend/app/schemas/print_queue.py
  58. 114 0
      backend/app/schemas/printer_ha_sensor.py
  59. 20 1
      backend/app/schemas/project.py
  60. 132 2
      backend/app/schemas/settings.py
  61. 42 1
      backend/app/schemas/slicer.py
  62. 2 2
      backend/app/schemas/slicer_presets.py
  63. 14 1
      backend/app/services/archive.py
  64. 168 0
      backend/app/services/bambu_cloud.py
  65. 129 7
      backend/app/services/bambu_ftp.py
  66. 721 13
      backend/app/services/bambu_mqtt.py
  67. 158 51
      backend/app/services/external_camera.py
  68. 163 61
      backend/app/services/filament_deficit.py
  69. 45 2
      backend/app/services/filament_requirements.py
  70. 69 0
      backend/app/services/finance_balance.py
  71. 293 0
      backend/app/services/finance_billing.py
  72. 298 0
      backend/app/services/finance_budget.py
  73. 75 0
      backend/app/services/finance_defaults.py
  74. 79 0
      backend/app/services/git_providers/base.py
  75. 33 24
      backend/app/services/git_providers/forgejo.py
  76. 106 0
      backend/app/services/git_providers/gitea.py
  77. 246 0
      backend/app/services/git_providers/github.py
  78. 261 0
      backend/app/services/git_providers/gitlab.py
  79. 50 0
      backend/app/services/github_backup.py
  80. 1957 0
      backend/app/services/github_restore.py
  81. 271 0
      backend/app/services/ha_sensor_manager.py
  82. 102 0
      backend/app/services/homeassistant.py
  83. 42 18
      backend/app/services/ldap_service.py
  84. 97 0
      backend/app/services/library_trash.py
  85. 11 0
      backend/app/services/log_health.py
  86. 18 12
      backend/app/services/makerworld.py
  87. 146 6
      backend/app/services/notification_service.py
  88. 8 30
      backend/app/services/pipeline_eligibility.py
  89. 543 0
      backend/app/services/print_batch.py
  90. 165 0
      backend/app/services/print_cost_estimate.py
  91. 2 0
      backend/app/services/print_log.py
  92. 736 86
      backend/app/services/print_scheduler.py
  93. 147 0
      backend/app/services/print_storage.py
  94. 96 5
      backend/app/services/printer_diagnostic.py
  95. 106 14
      backend/app/services/printer_manager.py
  96. 109 0
      backend/app/services/process_overrides.py
  97. 95 0
      backend/app/services/slice_output_check.py
  98. 191 12
      backend/app/services/slice_preview.py
  99. 233 12
      backend/app/services/slicer_api.py
  100. 223 7
      backend/app/services/spoolman_tracking.py

+ 4 - 1
.gitignore

@@ -60,7 +60,10 @@ firmware/
 # Node modules
 node_modules/
 
-data/
+# Runtime data dir (db, archives, backups). Anchored to the repo root on
+# purpose: a bare `data/` also matches frontend/src/data and
+# backend/app/data, which are source, not runtime state.
+/data/
 
 # Local-dev runtime caches (matplotlib MPLCONFIGDIR lands here when DATA_DIR
 # is unset, so base_dir resolves to the repo root). In Docker this sits

+ 3 - 4
.pre-commit-config.yaml

@@ -19,12 +19,11 @@ repos:
     rev: v5.0.0
     hooks:
       - id: trailing-whitespace
-        # Exclude static/ (build output) and gcode_viewer/ (vendored third-party
-        # assets — see gcode_viewer/VENDORED.md) so whitespace normalisation
+        # Exclude static/ (build output) so whitespace normalisation
         # doesn't drift the files away from upstream.
-        exclude: ^(static/|gcode_viewer/)
+        exclude: ^static/
       - id: end-of-file-fixer
-        exclude: ^(static/|gcode_viewer/)
+        exclude: ^static/
       - id: check-yaml
       - id: check-json
         exclude: ^(static/|frontend/tsconfig\.)

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 4 - 0
CHANGELOG.md


+ 7 - 0
CONTRIBUTING.md

@@ -340,6 +340,13 @@ pytest backend/tests/unit/         # Unit tests only
 pytest backend/tests/ --cov=backend  # With coverage
 ```
 
+`conftest.py` redirects `DATABASE_URL` to a throwaway SQLite file before any app
+module is imported, and aborts the run if that redirect did not take. Your `.env`
+is ignored for the duration, so the suite cannot reach the database you develop
+against — it exercises code paths that open their own sessions, and some of those
+write. Don't undo that override to "test against real data": point `DATABASE_URL`
+at a copy instead.
+
 **Frontend** — tests use [Vitest](https://vitest.dev/) and are in `frontend/src/__tests__/`:
 
 ```bash

+ 0 - 9
Dockerfile

@@ -72,15 +72,6 @@ COPY .git/HEAD ./.git/HEAD
 # Copy built frontend from builder stage
 COPY --from=frontend-builder /app/static ./static
 
-# Copy embedded GCode viewer static assets (PrettyGCode + Bambuddy adapter).
-# Served by the explicit @app.get("/gcode-viewer/{...}") routes in main.py,
-# which resolve files under (static_dir.parent / "gcode_viewer") = /app/gcode_viewer/.
-# Without this COPY the routes return a bare 404 at request time and the 3D
-# Preview iframe shows {"detail":"Not Found"} (see #1218). The directory is
-# vendored third-party JS — the Vite build does NOT stage it into static/,
-# the dev server serves it via a configureServer middleware that's dev-only.
-COPY gcode_viewer/ ./gcode_viewer/
-
 # Create data directories. Ownership is normalised at startup by the
 # entrypoint (chowns to PUID:PGID and drops privileges via gosu before
 # exec'ing the app), so we don't need a chmod 777 hack here — that was

+ 0 - 5
Dockerfile.test

@@ -23,11 +23,6 @@ RUN --mount=type=cache,target=/root/.cache/pip \
 COPY backend/ ./backend/
 COPY pyproject.toml ./
 
-# Embedded GCode viewer assets — required so the @app.get("/gcode-viewer/...")
-# packaging-regression test in tests/integration/test_gcode_viewer.py actually
-# runs instead of pytest-skipping with "index.html not present". Path matches
-# the production Dockerfile (static_dir.parent / "gcode_viewer" = /app/gcode_viewer/).
-COPY gcode_viewer/ ./gcode_viewer/
 
 # Create necessary directories
 RUN mkdir -p /app/data /app/logs /app/archive

+ 113 - 45
backend/app/api/routes/archives.py

@@ -30,13 +30,17 @@ from backend.app.schemas.print_log import PrintLogResponse
 from backend.app.schemas.slicer import SliceRequest
 from backend.app.services.archive import ArchiveService
 from backend.app.services.design_settings import overrides_from_config
+from backend.app.services.filament_requirements import annotate_rack_groups
+from backend.app.services.print_storage import REASON_INTERNAL_STORAGE, REASON_NO_EXTERNAL_STORAGE
+from backend.app.utils.archive_paths import archive_photos_dir, find_archive_photo
 from backend.app.utils.http import build_content_disposition
-from backend.app.utils.safe_path import safe_join_under
 from backend.app.utils.threemf_tools import (
+    default_plate_gcode_name,
     expand_to_project_slots,
     extract_embedded_presets_from_3mf,
     extract_nozzle_mapping_from_3mf,
     extract_project_filaments_from_3mf,
+    select_plate_gcode_name,
 )
 
 logger = logging.getLogger(__name__)
@@ -509,10 +513,23 @@ async def no_3mf_warning(
         )
     ),
 ):
-    """Whether to nudge the user about install step 4 ("Store sent files on
-    external storage"). True iff any archive in the last 30 days was created
-    via the no-3MF fallback path — that's the deterministic symptom of the
-    slicer-side variant of the setting being off.
+    """Whether to nudge the user about a print that archived without its 3MF,
+    and why. True iff any archive in the last 30 days was created via the
+    no-3MF fallback path.
+
+    Also returns ``reason``, because the advice differs and the original
+    single-cause wording sent people the wrong way. Historically the only
+    known cause was install step 4 ("Store sent files on external storage")
+    being off in the slicer, so the banner said so unconditionally. On
+    H2-series and P2S that advice is actively wrong: the setting is already on
+    and turning it on again changes nothing, because the printer keeps the
+    sliced file on internal storage that FTPS does not serve at all (#2780).
+
+    ``reason`` is the slug from :mod:`print_storage` when we recorded one,
+    else None for the original slicer-setting case. When archives disagree the
+    most specific known reason wins — one printer storing internally is a real
+    finding worth explaining, and it should not be masked by another printer's
+    plain missing-file fallback.
 
     Complements the connection-diagnostic ``external_storage`` check, which
     only catches the printer-side variant of the setting. On older slicers
@@ -533,10 +550,24 @@ async def no_3mf_warning(
     if user is not None and not can_read_all:
         conditions.append(PrintArchive.created_by_id == user.id)
     result = await db.execute(select(PrintArchive.extra_data).where(*conditions))
+    reasons: set[str] = set()
+    has_fallback = False
     for (extra_data,) in result.all():
-        if extra_data and extra_data.get("no_3mf_available"):
-            return {"has_fallback": True}
-    return {"has_fallback": False}
+        if not extra_data or not extra_data.get("no_3mf_available"):
+            continue
+        has_fallback = True
+        reason = extra_data.get("no_3mf_reason")
+        if reason:
+            reasons.add(reason)
+    if not has_fallback:
+        return {"has_fallback": False, "reason": None}
+    # Most specific first. Archives predating this field carry no reason at
+    # all, so an install with one H2C and three older printers still gets the
+    # H2C explanation rather than the generic one.
+    for candidate in (REASON_INTERNAL_STORAGE, REASON_NO_EXTERNAL_STORAGE):
+        if candidate in reasons:
+            return {"has_fallback": True, "reason": candidate}
+    return {"has_fallback": True, "reason": None}
 
 
 @router.get("/slim", response_model=list[ArchiveSlim])
@@ -2295,6 +2326,7 @@ async def scan_timelapse(
     from backend.app.services.bambu_ftp import (
         delete_archived_timelapse,
         download_file_bytes_async,
+        ftps_handshake_blocked,
         get_ftp_retry_settings,
         list_files_async,
         remote_file_settled,
@@ -2330,6 +2362,8 @@ async def scan_timelapse(
     # Different printer models use different paths
     files = []
     for timelapse_path in ["/timelapse", "/timelapse/video", "/record", "/recording"]:
+        if ftps_handshake_blocked(printer.ip_address):
+            break
         try:
             files = await list_files_async(
                 printer.ip_address, printer.access_code, timelapse_path, printer_model=printer.model
@@ -2339,7 +2373,18 @@ async def scan_timelapse(
         except Exception:
             continue
     if not files:
-        raise HTTPException(500, "Failed to connect to printer or no timelapse directory found")
+        # "Couldn't reach the printer" and "the printer has no timelapse
+        # directory" are different problems with different fixes, and both used
+        # to come back as one 500 (#2780). Nothing here will work while the
+        # printer's file service is not answering over TLS, so say that rather
+        # than reporting an empty directory.
+        if ftps_handshake_blocked(printer.ip_address):
+            raise HTTPException(
+                503,
+                f"Printer {printer.ip_address} is not answering its file service over TLS. "
+                "Bambuddy will try again shortly.",
+            )
+        raise HTTPException(404, "No timelapse directory found on the printer")
 
     # Look for matching timelapse
     matching_file = None
@@ -2887,10 +2932,10 @@ async def upload_photo(
     if not file.filename or not file.filename.lower().endswith((".jpg", ".jpeg", ".png", ".webp")):
         raise HTTPException(400, "File must be an image (.jpg, .jpeg, .png, .webp)")
 
-    # Get archive directory
-    archive_dir = settings.base_dir / Path(archive.file_path).parent
-    photos_dir = archive_dir / "photos"
-    photos_dir.mkdir(exist_ok=True)
+    # Get archive directory. parents=True because an archive with no 3MF owns
+    # <archive_dir>/<id>/, which nothing else has necessarily created yet.
+    photos_dir = archive_photos_dir(archive)
+    photos_dir.mkdir(parents=True, exist_ok=True)
 
     # Generate unique filename
     import uuid
@@ -2937,15 +2982,14 @@ async def get_photo(
     if not archive.photos or filename not in archive.photos:
         raise HTTPException(404, "Photo not found")
 
-    archive_dir = settings.base_dir / Path(archive.file_path).parent
-    photos_dir = archive_dir / "photos"
     # Defence-in-depth: even though the membership check above already
-    # constrains `filename` to UUID-generated names from upload, the
-    # resolve + containment check guards against future code paths that
-    # might populate `archive.photos` from a less-trusted source.
-    photo_path = safe_join_under(photos_dir, filename)
+    # constrains `filename` to UUID-generated names from upload,
+    # find_archive_photo resolves and containment-checks each candidate,
+    # guarding against future code paths that might populate
+    # `archive.photos` from a less-trusted source.
+    photo_path = find_archive_photo(archive, filename)
 
-    if not photo_path.exists():
+    if photo_path is None:
         raise HTTPException(404, "Photo not found")
 
     # Determine media type
@@ -2981,11 +3025,11 @@ async def delete_photo(
     if not archive.photos or filename not in archive.photos:
         raise HTTPException(404, "Photo not found")
 
-    # Delete file — same defence-in-depth as get_photo above.
-    archive_dir = settings.base_dir / Path(archive.file_path).parent
-    photos_dir = archive_dir / "photos"
-    photo_path = safe_join_under(photos_dir, filename)
-    if photo_path.exists():
+    # Delete file — same lookup as get_photo above, so a photo that is
+    # readable is also deletable. Removing the name while leaving the file is
+    # how a no-3MF archive accumulated photos nobody could see or remove.
+    photo_path = find_archive_photo(archive, filename)
+    if photo_path is not None:
         photo_path.unlink()
 
     # Update archive photos list
@@ -3301,8 +3345,9 @@ async def get_gcode(
 
     When *plate* is provided, returns the G-code for that specific plate
     (e.g. ``?plate=2`` returns ``Metadata/plate_2.gcode``). If omitted, falls
-    back to the first plate found in the archive (preserving the original
-    behaviour for callers that predate the multi-plate viewer).
+    back to the archive's lowest-numbered plate — not the first member in the
+    zip, which is whatever order the slicer wrote and routinely puts plate 2
+    ahead of plate 1.
     """
     user, can_read_all = auth_result
     service = ArchiveService(db)
@@ -3326,25 +3371,11 @@ async def get_gcode(
                 )
 
             if plate is not None:
-                # Resolve plate → filename via the same parsing the plates
-                # endpoint uses (int() on the suffix), so zero-padded names
-                # like plate_01.gcode are found when the plates endpoint
-                # reported index 1.
-                selected = None
-                for gf in gcode_files:
-                    if not gf.startswith("Metadata/plate_"):
-                        continue
-                    suffix = gf[len("Metadata/plate_") : -len(".gcode")]
-                    try:
-                        if int(suffix) == plate:
-                            selected = gf
-                            break
-                    except ValueError:
-                        continue
+                selected = select_plate_gcode_name(gcode_files, plate)
                 if selected is None:
                     raise HTTPException(404, f"Plate {plate} not found in this archive")
             else:
-                selected = gcode_files[0]
+                selected = default_plate_gcode_name(gcode_files)
 
             gcode_content = zf.read(selected).decode("utf-8")
             return Response(content=gcode_content, media_type="text/plain")
@@ -3429,10 +3460,28 @@ async def get_plate_preview(
 async def upload_archive(
     file: UploadFile = File(...),
     printer_id: int | None = None,
+    prefer_filename_for_name: bool = Query(
+        False,
+        description=(
+            "Name the archive after the uploaded filename instead of the print_name "
+            "embedded in the 3MF's metadata. Off by default, which keeps the embedded "
+            "name. Turn it on when the filename you send is the meaningful one — an "
+            "integration naming files after its own jobs, or a file whose embedded "
+            "title is a stale name from whoever originally sliced it."
+        ),
+    ),
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_CREATE),
 ):
-    """Manually upload a 3MF file to archive."""
+    """Manually upload a 3MF file to archive.
+
+    prefer_filename_for_name is the same flag the FTP review flow and
+    virtual-printer dispatch already pass to ArchiveService.archive_print —
+    this endpoint just didn't expose it (#1152 follow-up). Those callers derive
+    it from the VP-scoped `virtual_printer_archive_name_source` setting; here it
+    is per-request, because the caller is an API client that knows whether the
+    filename it sent is the meaningful one (#2609).
+    """
     if not file.filename or not file.filename.endswith(".3mf"):
         raise HTTPException(400, "File must be a .3mf file")
 
@@ -3458,6 +3507,7 @@ async def upload_archive(
             printer_id=printer_id,
             source_file=temp_path,
             created_by_id=current_user.id if current_user else None,
+            prefer_filename_for_name=prefer_filename_for_name,
         )
 
         if not archive:
@@ -3473,10 +3523,22 @@ async def upload_archive(
 async def upload_archives_bulk(
     files: list[UploadFile] = File(...),
     printer_id: int | None = None,
+    prefer_filename_for_name: bool = Query(
+        False,
+        description=(
+            "Name each archive after its uploaded filename instead of the print_name "
+            "embedded in the 3MF's metadata. Applies to every file in the batch. Off "
+            "by default, which keeps the embedded name."
+        ),
+    ),
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_CREATE),
 ):
-    """Bulk upload multiple 3MF files to archive."""
+    """Bulk upload multiple 3MF files to archive.
+
+    prefer_filename_for_name applies to every file in the batch. See
+    upload_archive for the flag's lineage.
+    """
     from backend.app.api.routes.library import validate_print_file_upload
 
     results = []
@@ -3511,6 +3573,7 @@ async def upload_archives_bulk(
                 printer_id=printer_id,
                 source_file=temp_path,
                 created_by_id=current_user.id if current_user else None,
+                prefer_filename_for_name=prefer_filename_for_name,
             )
 
             if archive:
@@ -4091,6 +4154,11 @@ async def get_filament_requirements(
                 for filament in filaments:
                     filament["nozzle_id"] = nozzle_mapping.get(filament["slot_id"])
 
+            # Nozzle-rack machines (#1784): the print dialog offers a rack
+            # position per filament group, which needs the group table as well
+            # as the carriage above.
+            annotate_rack_groups(filaments, file_path, plate_id)
+
     except Exception as e:
         logger.warning("Failed to parse filament requirements from archive %s: %s", archive_id, e)
 

+ 50 - 12
backend/app/api/routes/auth.py

@@ -21,6 +21,7 @@ from backend.app.core.auth import (
     RequirePermissionIfAuthEnabled,
     _is_token_fresh,
     _validate_api_key,
+    apikey_effective_permissions,
     authenticate_user,
     authenticate_user_by_email,
     create_access_token,
@@ -30,13 +31,13 @@ from backend.app.core.auth import (
     get_user_by_email,
     get_user_by_username,
     is_jti_revoked,
+    resolve_apikey_owner,
     resolve_session_max_minutes,
     revoke_jti,
     security,
 )
 from backend.app.core.database import async_session, get_db
 from backend.app.core.oidc_env import env_bool
-from backend.app.core.permissions import ALL_PERMISSIONS
 from backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent, EventType, TokenType
 from backend.app.models.group import Group
 from backend.app.models.settings import Settings
@@ -68,6 +69,7 @@ from backend.app.services.email_service import (
     save_smtp_settings,
     send_email,
 )
+from backend.app.services.finance_defaults import ensure_user_finance_defaults
 
 _logger = logging.getLogger(__name__)
 
@@ -88,17 +90,47 @@ def _user_to_response(user: User) -> UserResponse:
     )
 
 
-def _api_key_to_user_response(api_key) -> UserResponse:
-    """Create a synthetic admin UserResponse for a valid API key."""
+async def _api_key_to_user_response(db: AsyncSession, api_key) -> UserResponse:
+    """Describe a valid API key as the identity it actually carries (#1894).
+
+    Until 0.2.5 this returned a synthetic admin: ``id=0``, ``role="admin"``,
+    ``is_admin=True`` and every permission in the enum. That was wrong in both
+    directions. A key cannot perform administrative operations at all --
+    ``_check_apikey_permissions`` denies every permission that is not in the
+    scope allowlist -- so a client that builds its UI from this response (which
+    is exactly what a native client does) rendered admin actions that 403 on
+    use, and had no way to learn the id its own prints are filed under.
+
+    Now: identity comes from the key's owner, and ``permissions`` is the set the
+    key can genuinely exercise. ``is_admin`` is always False because no key can
+    reach an administrative route regardless of who owns it.
+
+    Legacy keys predating per-user ownership (``user_id IS NULL``) have no
+    identity to report, so they keep ``id=0`` and the ``api-key:`` username --
+    but they stop claiming admin. ``created_at`` describes the credential in
+    both branches, unchanged.
+    """
+    # Same resolution the permission gate uses, so what is reported here and
+    # what is enforced there cannot drift -- including the 403 when the owner
+    # has been deactivated, which makes the key dead rather than anonymous.
+    owner = await resolve_apikey_owner(db, api_key)
     return UserResponse(
-        id=0,
-        username=f"api-key:{api_key.key_prefix}",
+        id=owner.id if owner else 0,
+        username=owner.username if owner else f"api-key:{api_key.key_prefix}",
+        # Withheld on purpose: the owner's email is not needed to resolve
+        # identity, and this response is reachable by anyone holding the key.
         email=None,
-        role="admin",
+        # Deprecated free-text field; "user" is the existing value meaning
+        # "not an admin". Inventing an "api_key" role here would put a third
+        # value into a field callers compare against string literals.
+        role="user",
         is_active=True,
-        is_admin=True,
+        is_admin=False,
+        auth_source=getattr(owner, "auth_source", "local") if owner else "local",
+        # The key is not a group member -- listing the owner's groups would
+        # imply capabilities the key does not inherit.
         groups=[],
-        permissions=sorted(ALL_PERMISSIONS),
+        permissions=apikey_effective_permissions(api_key, owner),
         created_at=api_key.created_at.isoformat(),
     )
 
@@ -480,6 +512,9 @@ async def login(raw_request: Request, request: LoginRequest, response: Response,
                     if user and ldap_user:
                         # Update email and group mappings on each login
                         await _sync_ldap_user(db, user, ldap_user, ldap_config)
+                        # Keep finance defaults idempotently in sync for LDAP users
+                        # (wallet + private cost center + self-membership).
+                        await ensure_user_finance_defaults(db, user)
         except Exception as e:  # SEC-AUTH-EXC: LDAP failure sets ldap_user=None, downstream local-auth path runs with its own credential check (no implicit grant)
             import logging
 
@@ -633,8 +668,9 @@ async def get_current_user_info(
     """Get current user information.
 
     Accepts JWT tokens (via Authorization: Bearer header) and API keys
-    (via X-API-Key header or Authorization: Bearer bb_xxx).
-    API keys return a synthetic admin user with all permissions.
+    (via X-API-Key header or Authorization: Bearer bb_xxx). API keys report
+    their owner's identity and the permissions the key can actually exercise
+    -- see ``_api_key_to_user_response``.
     """
     import jwt
     from jwt.exceptions import PyJWTError as JWTError
@@ -643,7 +679,7 @@ async def get_current_user_info(
     if x_api_key:
         api_key = await _validate_api_key(db, x_api_key)
         if api_key:
-            return _api_key_to_user_response(api_key)
+            return await _api_key_to_user_response(db, api_key)
 
     # Check for Bearer token (could be JWT or API key)
     if credentials is not None:
@@ -652,7 +688,7 @@ async def get_current_user_info(
         if token.startswith("bb_"):
             api_key = await _validate_api_key(db, token)
             if api_key:
-                return _api_key_to_user_response(api_key)
+                return await _api_key_to_user_response(db, api_key)
             raise HTTPException(
                 status_code=status.HTTP_401_UNAUTHORIZED,
                 detail="Invalid API key",
@@ -1341,6 +1377,8 @@ async def _provision_ldap_user(db: AsyncSession, ldap_user, ldap_config) -> User
         new_user.groups = list(groups_result.scalars().all())
 
     db.add(new_user)
+    await db.flush()
+    await ensure_user_finance_defaults(db, new_user)
     await db.commit()
     await db.refresh(new_user)
     logger.info("Auto-provisioned LDAP user: %s (groups: %s)", new_user.username, mapped_group_names)

+ 2 - 0
backend/app/api/routes/cloud.py

@@ -528,6 +528,7 @@ async def login(
             message=result.get("message", "Unknown error"),
             verification_type=result.get("verification_type"),
             tfa_key=result.get("tfa_key"),
+            reason=result.get("reason"),
         )
     except BambuCloudAuthError as e:
         raise HTTPException(status_code=401, detail=str(e))
@@ -573,6 +574,7 @@ async def verify_code(
             success=result.get("success", False),
             needs_verification=False,
             message=result.get("message", "Unknown error"),
+            reason=result.get("reason"),
         )
     except BambuCloudAuthError as e:
         raise HTTPException(status_code=401, detail=str(e))

+ 1044 - 0
backend/app/api/routes/finance.py

@@ -0,0 +1,1044 @@
+import calendar
+from datetime import datetime, timezone
+from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy import case, func, or_, select
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_auth_if_enabled
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.finance import (
+    BudgetReservation,
+    CostCenter,
+    CostCenterMember,
+    TransactionType,
+    UserWallet,
+    WalletTransaction,
+    normalize_transaction_type,
+)
+from backend.app.models.settings import Settings
+from backend.app.models.user import User
+from backend.app.schemas.finance import (
+    CostCenterBudgetUpdateRequest,
+    CostCenterCreateRequest,
+    CostCenterDetailResponse,
+    CostCenterMemberRequest,
+    CostCenterMemberResponse,
+    CostCenterSummaryResponse,
+    CostCenterUpdateRequest,
+    ManualPrintRequest,
+    TransactionEditRequest,
+    WalletAdjustmentRequest,
+    WalletAdjustmentResponse,
+    WalletBalanceResponse,
+    WalletTransactionListResponse,
+    WalletTransactionResponse,
+)
+from backend.app.services.finance_balance import (
+    calculate_personal_balance,
+    is_personal_transaction,
+    personal_balance_condition,
+    sync_personal_wallet_balance,
+)
+from backend.app.services.finance_budget import get_cost_center_reserved_map
+
+router = APIRouter(prefix="/finance", tags=["finance"])
+
+
+def _serialize_wallet_transaction(tx: WalletTransaction) -> WalletTransactionResponse:
+    transaction_type = (
+        tx.transaction_type.value if isinstance(tx.transaction_type, TransactionType) else tx.transaction_type
+    )
+    return WalletTransactionResponse.model_construct(
+        id=tx.id,
+        user_id=tx.user_id,
+        cost_center_id=tx.cost_center_id,
+        transaction_type=transaction_type,
+        amount=tx.amount,
+        balance_after=tx.balance_after,
+        description=tx.description,
+        created_by_user_id=tx.created_by_user_id,
+        print_run_id=tx.print_run_id,
+        print_archive_id=tx.print_archive_id,
+        print_queue_id=tx.print_queue_id,
+        created_at=tx.created_at,
+    )
+
+
+def _clamp_day(year: int, month: int, desired_day: int) -> int:
+    return min(max(1, desired_day), calendar.monthrange(year, month)[1])
+
+
+async def _get_budget_window_start_utc(db: AsyncSession) -> datetime:
+    """Resolve monthly budget window start in UTC using configurable reset day/timezone.
+
+    Defaults preserve current behavior: day=1, timezone=UTC.
+    """
+    desired_day = 1
+    tz_name = "UTC"
+
+    result = await db.execute(
+        select(Settings).where(Settings.key.in_(["finance_budget_reset_day", "finance_budget_reset_timezone"]))
+    )
+    for setting in result.scalars().all():
+        if setting.key == "finance_budget_reset_day":
+            try:
+                parsed = int(setting.value)
+                if 1 <= parsed <= 31:
+                    desired_day = parsed
+            except (TypeError, ValueError):
+                pass
+        elif setting.key == "finance_budget_reset_timezone":
+            value = (setting.value or "").strip()
+            if value:
+                tz_name = value
+
+    try:
+        tz = ZoneInfo(tz_name)
+    except ZoneInfoNotFoundError:
+        tz = timezone.utc
+
+    now_local = datetime.now(tz)
+    current_month_reset_day = _clamp_day(now_local.year, now_local.month, desired_day)
+
+    if now_local.day >= current_month_reset_day:
+        start_local = datetime(now_local.year, now_local.month, current_month_reset_day, tzinfo=tz)
+    else:
+        prev_year = now_local.year
+        prev_month = now_local.month - 1
+        if prev_month == 0:
+            prev_month = 12
+            prev_year -= 1
+        prev_month_reset_day = _clamp_day(prev_year, prev_month, desired_day)
+        start_local = datetime(prev_year, prev_month, prev_month_reset_day, tzinfo=tz)
+
+    return start_local.astimezone(timezone.utc)
+
+
+async def _get_cost_center_usage_maps(
+    db: AsyncSession,
+    cost_center_ids: list[int],
+) -> tuple[dict[int, float], dict[int, float]]:
+    if not cost_center_ids:
+        return {}, {}
+
+    spend_expr = case((WalletTransaction.amount < 0, -WalletTransaction.amount), else_=0.0)
+
+    total_rows = await db.execute(
+        select(WalletTransaction.cost_center_id, func.coalesce(func.sum(spend_expr), 0.0))
+        .where(
+            WalletTransaction.cost_center_id.in_(cost_center_ids),
+            WalletTransaction.cost_center_id.is_not(None),
+            WalletTransaction.is_voided.is_(False),
+        )
+        .group_by(WalletTransaction.cost_center_id)
+    )
+
+    budget_window_start_utc = await _get_budget_window_start_utc(db)
+
+    month_rows = await db.execute(
+        select(WalletTransaction.cost_center_id, func.coalesce(func.sum(spend_expr), 0.0))
+        .where(
+            WalletTransaction.cost_center_id.in_(cost_center_ids),
+            WalletTransaction.cost_center_id.is_not(None),
+            WalletTransaction.is_voided.is_(False),
+            WalletTransaction.created_at >= budget_window_start_utc,
+        )
+        .group_by(WalletTransaction.cost_center_id)
+    )
+
+    total_map = {int(center_id): float(value) for center_id, value in total_rows.all() if center_id is not None}
+    month_map = {int(center_id): float(value) for center_id, value in month_rows.all() if center_id is not None}
+    return total_map, month_map
+
+
+async def _get_cost_center_balance_map(
+    db: AsyncSession,
+    cost_center_ids: list[int],
+) -> dict[int, float]:
+    if not cost_center_ids:
+        return {}
+
+    rows = await db.execute(
+        select(WalletTransaction.cost_center_id, func.coalesce(func.sum(WalletTransaction.amount), 0.0))
+        .where(
+            WalletTransaction.cost_center_id.in_(cost_center_ids),
+            WalletTransaction.cost_center_id.is_not(None),
+            WalletTransaction.is_voided.is_(False),
+        )
+        .group_by(WalletTransaction.cost_center_id)
+    )
+    return {int(center_id): float(value) for center_id, value in rows.all() if center_id is not None}
+
+
+async def _get_cost_center_reserved_map(
+    db: AsyncSession,
+    cost_center_ids: list[int],
+) -> dict[int, float]:
+    return await get_cost_center_reserved_map(db, cost_center_ids)
+
+
+def _budget_mode_and_limit(center: CostCenter) -> tuple[str, float | None]:
+    # Monthly takes precedence if legacy data still has both set.
+    if center.monthly_budget is not None:
+        return "monthly", float(center.monthly_budget)
+    if center.total_budget is not None:
+        return "total", float(center.total_budget)
+    return "none", None
+
+
+def _to_cost_center_summary(
+    center: CostCenter,
+    *,
+    can_print: bool,
+    total_usage: float,
+    month_usage: float,
+    total_balance: float,
+    reserved: float = 0.0,
+) -> CostCenterSummaryResponse:
+    budget_mode, budget_limit = _budget_mode_and_limit(center)
+    budget_used = month_usage if budget_mode == "monthly" else total_usage if budget_mode == "total" else None
+    budget_available = (
+        max(0.0, budget_limit - budget_used - reserved)
+        if budget_limit is not None and budget_used is not None
+        else None
+    )
+
+    return CostCenterSummaryResponse(
+        id=center.id,
+        name=center.name,
+        is_private=center.is_private,
+        owner_user_id=center.owner_user_id,
+        is_active=center.is_active,
+        total_balance=total_balance,
+        total_budget=center.total_budget,
+        monthly_budget=center.monthly_budget,
+        budget_mode=budget_mode,
+        budget_limit=budget_limit,
+        budget_used=budget_used,
+        budget_available=budget_available,
+        can_print=can_print,
+    )
+
+
+async def _require_authenticated_user(current_user: User | None) -> User:
+    if current_user is None:
+        raise HTTPException(status_code=401, detail="Authentication required")
+    return current_user
+
+
+def _has_cost_center_admin_access(user: User) -> bool:
+    return user.has_any_permission(
+        Permission.COST_CENTERS_READ_ALL.value,
+        Permission.COST_CENTERS_MODIFY.value,
+        Permission.COST_CENTERS_CREATE.value,
+    )
+
+
+async def _require_cost_center_admin_access(current_user: User | None) -> User:
+    user = await _require_authenticated_user(current_user)
+    if not _has_cost_center_admin_access(user):
+        raise HTTPException(status_code=403, detail="Missing required permissions for cost center administration")
+    return user
+
+
+async def _get_or_create_wallet(db: AsyncSession, user_id: int) -> UserWallet:
+    result = await db.execute(select(UserWallet).where(UserWallet.user_id == user_id))
+    wallet = result.scalar_one_or_none()
+    if wallet:
+        return wallet
+
+    wallet = UserWallet(user_id=user_id, balance=0.0, currency="EUR")
+    db.add(wallet)
+    await db.flush()
+    await db.refresh(wallet)
+    return wallet
+
+
+async def _get_user_or_404(db: AsyncSession, user_id: int) -> User:
+    result = await db.execute(select(User).where(User.id == user_id))
+    user = result.scalar_one_or_none()
+    if user is None:
+        raise HTTPException(status_code=404, detail="User not found")
+    return user
+
+
+async def _get_cost_center_or_404(db: AsyncSession, cost_center_id: int) -> CostCenter:
+    result = await db.execute(
+        select(CostCenter).options(selectinload(CostCenter.members)).where(CostCenter.id == cost_center_id)
+    )
+    center = result.scalar_one_or_none()
+    if center is None:
+        raise HTTPException(status_code=404, detail="Cost center not found")
+    return center
+
+
+def _to_balance_response(wallet: UserWallet) -> WalletBalanceResponse:
+    return WalletBalanceResponse(
+        user_id=wallet.user_id,
+        balance=wallet.balance,
+        currency=wallet.currency,
+        updated_at=wallet.updated_at,
+    )
+
+
+async def _get_wallet_balance_read_only(db: AsyncSession, user_id: int) -> WalletBalanceResponse:
+    """Return a balance without creating a wallet row from a GET request."""
+    wallet = await db.scalar(select(UserWallet).where(UserWallet.user_id == user_id))
+    if wallet is not None:
+        return _to_balance_response(wallet)
+    return WalletBalanceResponse(
+        user_id=user_id,
+        balance=await calculate_personal_balance(db, user_id),
+        currency="EUR",
+        updated_at=None,
+    )
+
+
+async def _build_personal_balance_map(
+    db: AsyncSession,
+    user_id: int,
+    transaction_ids: list[int],
+) -> dict[int, float]:
+    """Return running balances only for transactions on the requested page."""
+    if not transaction_ids:
+        return {}
+
+    running = (
+        select(
+            WalletTransaction.id.label("transaction_id"),
+            func.sum(WalletTransaction.amount)
+            .over(order_by=(WalletTransaction.created_at.asc(), WalletTransaction.id.asc()))
+            .label("running_balance"),
+        )
+        .outerjoin(CostCenter, WalletTransaction.cost_center_id == CostCenter.id)
+        .where(
+            WalletTransaction.user_id == user_id,
+            WalletTransaction.is_voided.is_(False),
+            personal_balance_condition(user_id),
+        )
+        .subquery()
+    )
+    result = await db.execute(
+        select(running.c.transaction_id, running.c.running_balance).where(running.c.transaction_id.in_(transaction_ids))
+    )
+    return {int(transaction_id): round(float(balance), 2) for transaction_id, balance in result.all()}
+
+
+async def _create_wallet_adjustment(
+    db: AsyncSession,
+    *,
+    target_user_id: int,
+    actor_user_id: int,
+    amount: float,
+    transaction_type: str,
+    description: str | None,
+    cost_center_id: int | None,
+) -> WalletAdjustmentResponse:
+    transaction_type = normalize_transaction_type(transaction_type)
+
+    if cost_center_id is not None:
+        await _get_cost_center_or_404(db, cost_center_id)
+
+    wallet = await _get_or_create_wallet(db, target_user_id)
+    affects_personal_wallet = await is_personal_transaction(db, target_user_id, cost_center_id)
+
+    # Calculate balance_after for this specific transaction context
+    if cost_center_id is None:
+        # Personal transaction: validate and update user wallet
+        new_balance = wallet.balance + amount
+        if new_balance < 0:
+            raise HTTPException(status_code=400, detail="Insufficient balance for withdrawal")
+        balance_after = new_balance
+    else:
+        # Cost-center transaction: validate against cost center balance only (global, not per-user)
+        result = await db.execute(
+            select(func.coalesce(func.sum(WalletTransaction.amount), 0.0)).where(
+                WalletTransaction.cost_center_id == cost_center_id,
+                WalletTransaction.is_voided.is_(False),
+            )
+        )
+        current_cc_balance = float(result.scalar() or 0.0)
+        new_cc_balance = current_cc_balance + amount
+        if new_cc_balance < 0:
+            raise HTTPException(status_code=400, detail="Insufficient cost center balance for withdrawal")
+        balance_after = new_cc_balance
+        if affects_personal_wallet and wallet.balance + amount < 0:
+            raise HTTPException(status_code=400, detail="Insufficient balance for withdrawal")
+
+    tx = WalletTransaction(
+        user_id=target_user_id,
+        cost_center_id=cost_center_id,
+        transaction_type=transaction_type,
+        amount=amount,
+        balance_after=balance_after,
+        description=description,
+        created_by_user_id=actor_user_id,
+    )
+    db.add(tx)
+    await db.flush()
+    await sync_personal_wallet_balance(db, wallet)
+    await db.commit()
+    await db.refresh(wallet)
+    await db.refresh(tx)
+
+    # Return appropriate balance based on transaction type
+    if affects_personal_wallet:
+        # Personal transaction: return user wallet balance
+        response_balance = _to_balance_response(wallet)
+    else:
+        # Cost-center transaction: return cost-center balance as if it were a wallet
+        response_balance = WalletBalanceResponse(
+            user_id=target_user_id,
+            balance=balance_after,
+            currency=wallet.currency,
+            updated_at=tx.created_at,
+        )
+
+    return WalletAdjustmentResponse(
+        transaction=_serialize_wallet_transaction(tx),
+        balance=response_balance,
+    )
+
+
+@router.get("/me/balance", response_model=WalletBalanceResponse)
+async def get_my_balance(
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_READ_OWN),
+):
+    """Return the current user's wallet balance."""
+    user = await _require_authenticated_user(current_user)
+    return await _get_wallet_balance_read_only(db, user.id)
+
+
+@router.get("/me/transactions", response_model=WalletTransactionListResponse)
+async def get_my_transactions(
+    limit: int = Query(50, ge=1, le=500),
+    offset: int = Query(0, ge=0),
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_READ_OWN),
+):
+    """Return wallet ledger entries for the current user."""
+    user = await _require_authenticated_user(current_user)
+
+    total_result = await db.execute(
+        select(func.count(WalletTransaction.id)).where(
+            WalletTransaction.user_id == user.id,
+            WalletTransaction.is_voided.is_(False),
+        )
+    )
+    total = int(total_result.scalar_one() or 0)
+
+    result = await db.execute(
+        select(WalletTransaction)
+        .where(WalletTransaction.user_id == user.id, WalletTransaction.is_voided.is_(False))
+        .order_by(WalletTransaction.created_at.desc(), WalletTransaction.id.desc())
+        .limit(limit)
+        .offset(offset)
+    )
+    transactions = result.scalars().all()
+    personal_balance_map = await _build_personal_balance_map(db, user.id, [tx.id for tx in transactions])
+    return WalletTransactionListResponse(
+        items=[
+            _serialize_wallet_transaction(tx).model_copy(
+                update={"balance_after": personal_balance_map.get(tx.id, tx.balance_after)}
+            )
+            for tx in transactions
+        ],
+        total=total,
+        limit=limit,
+        offset=offset,
+    )
+
+
+@router.get("/transactions", response_model=WalletTransactionListResponse)
+async def get_all_transactions(
+    limit: int = Query(50, ge=1, le=500),
+    offset: int = Query(0, ge=0),
+    user_id: int | None = Query(None, description="Optional filter by user id"),
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_READ_ALL),
+):
+    """Return wallet ledger entries across users for admin finance view."""
+    await _require_authenticated_user(current_user)
+
+    conditions = [WalletTransaction.is_voided.is_(False)]
+    if user_id is not None:
+        await _get_user_or_404(db, user_id)
+        conditions.append(WalletTransaction.user_id == user_id)
+
+    total_result = await db.execute(select(func.count(WalletTransaction.id)).where(*conditions))
+    total = int(total_result.scalar_one() or 0)
+
+    result = await db.execute(
+        select(WalletTransaction)
+        .where(*conditions)
+        .order_by(WalletTransaction.created_at.desc(), WalletTransaction.id.desc())
+        .limit(limit)
+        .offset(offset)
+    )
+    transactions = result.scalars().all()
+    return WalletTransactionListResponse(
+        items=[_serialize_wallet_transaction(tx) for tx in transactions],
+        total=total,
+        limit=limit,
+        offset=offset,
+    )
+
+
+async def _rebuild_wallet_ledger_for_user(db: AsyncSession, user_id: int) -> None:
+    """Rebuild through the same canonical ledger repair used at startup."""
+    from backend.app.core.database import repair_wallet_ledger_internal
+
+    await repair_wallet_ledger_internal(db)
+
+
+@router.delete("/transactions/{transaction_id}")
+async def delete_transaction(
+    transaction_id: int,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
+):
+    """Delete a wallet transaction and rebuild the user's ledger to keep balances consistent."""
+    await _require_authenticated_user(current_user)
+
+    result = await db.execute(
+        select(WalletTransaction).where(
+            WalletTransaction.id == transaction_id,
+            WalletTransaction.is_voided.is_(False),
+        )
+    )
+    tx = result.scalar_one_or_none()
+    if tx is None:
+        raise HTTPException(status_code=404, detail="Transaction not found")
+
+    user_id = tx.user_id
+
+    # Keep a hidden, zero-effect tombstone for the billing_run_id. A delayed
+    # duplicate completion therefore cannot recreate this deliberately removed
+    # charge, while a later reprint of the same archive has its own run ID and
+    # remains billable.
+    tx.is_voided = True
+    await db.flush()
+
+    await _rebuild_wallet_ledger_for_user(db, user_id)
+
+    return {"status": "success"}
+
+
+@router.patch("/transactions/{transaction_id}", response_model=WalletTransactionResponse)
+async def edit_transaction(
+    transaction_id: int,
+    request: TransactionEditRequest,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
+):
+    """Edit a wallet transaction (user_id, cost_center_id, amount, description) and rebuild ledger."""
+    await _require_authenticated_user(current_user)
+
+    result = await db.execute(
+        select(WalletTransaction).where(
+            WalletTransaction.id == transaction_id,
+            WalletTransaction.is_voided.is_(False),
+        )
+    )
+    tx = result.scalar_one_or_none()
+    if tx is None:
+        raise HTTPException(status_code=404, detail="Transaction not found")
+
+    # Apply edits
+    if request.user_id is not None:
+        await _get_user_or_404(db, request.user_id)
+        tx.user_id = request.user_id
+
+    if "cost_center_id" in request.model_fields_set:
+        if request.cost_center_id is not None:
+            await _get_cost_center_or_404(db, request.cost_center_id)
+        tx.cost_center_id = request.cost_center_id
+
+    if request.amount is not None:
+        tx.amount = request.amount
+
+    if request.description is not None:
+        # Append "(Admin edit)" marker if not already present
+        new_desc = request.description
+        if not new_desc.endswith("(Admin edit)"):
+            new_desc = f"{new_desc} (Admin edit)"
+        tx.description = new_desc
+
+    db.add(tx)
+    await db.flush()
+
+    # Rebuild full ledger using the current session
+    from backend.app.core.database import repair_wallet_ledger_internal
+
+    await repair_wallet_ledger_internal(db)
+    await db.refresh(tx)
+
+    return tx
+
+
+@router.post("/transactions/manual", response_model=WalletTransactionResponse)
+async def create_manual_print(
+    request: ManualPrintRequest,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
+):
+    """Create a manual print charge transaction (for admin purposes)."""
+    await _require_authenticated_user(current_user)
+    await _get_user_or_404(db, request.user_id)
+    await _get_cost_center_or_404(db, request.cost_center_id)
+
+    from datetime import timezone
+
+    # Use provided created_at or current time
+    created_at = request.created_at or datetime.now(timezone.utc)
+
+    # Ensure manual print charges are negative amounts (charges reduce wallet)
+    amount = request.amount
+    if amount > 0:
+        amount = -abs(amount)
+
+    # Create transaction
+    tx = WalletTransaction(
+        user_id=request.user_id,
+        cost_center_id=request.cost_center_id,
+        transaction_type=TransactionType.MANUAL_ADJUSTMENT.value,
+        amount=amount,
+        balance_after=None,  # Will be set by repair_wallet_ledger_internal
+        description=request.description or "Manual print charge",
+        created_by_user_id=current_user.id if current_user else None,
+        created_at=created_at,
+    )
+    db.add(tx)
+    await db.flush()
+
+    # Rebuild full ledger using the current session
+    from backend.app.core.database import repair_wallet_ledger_internal
+
+    await repair_wallet_ledger_internal(db)
+    await db.refresh(tx)
+
+    return tx
+
+
+@router.get("/cost-centers/mine", response_model=list[CostCenterSummaryResponse])
+async def get_my_cost_centers(
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = Depends(require_auth_if_enabled),
+):
+    """Return private and assigned cost centers for the current user."""
+    user = await _require_authenticated_user(current_user)
+
+    result = await db.execute(
+        select(CostCenter, CostCenterMember.can_print)
+        .outerjoin(
+            CostCenterMember,
+            (CostCenterMember.cost_center_id == CostCenter.id) & (CostCenterMember.user_id == user.id),
+        )
+        .where(
+            CostCenter.is_active.is_(True),
+            or_(
+                (CostCenter.is_private.is_(True) & (CostCenter.owner_user_id == user.id)),
+                (CostCenterMember.user_id == user.id),
+            ),
+        )
+        .order_by(CostCenter.is_private.desc(), CostCenter.name.asc())
+    )
+
+    rows = result.all()
+    centers_only = [center for center, _ in rows]
+    center_ids = [center.id for center in centers_only]
+    total_usage_map, month_usage_map = await _get_cost_center_usage_maps(db, center_ids)
+    total_balance_map = await _get_cost_center_balance_map(db, center_ids)
+    reserved_map = await _get_cost_center_reserved_map(db, center_ids)
+
+    centers: list[CostCenterSummaryResponse] = []
+    for center, can_print in rows:
+        centers.append(
+            _to_cost_center_summary(
+                center,
+                can_print=True if center.is_private and center.owner_user_id == user.id else bool(can_print),
+                total_usage=total_usage_map.get(center.id, 0.0),
+                month_usage=month_usage_map.get(center.id, 0.0),
+                total_balance=total_balance_map.get(center.id, 0.0),
+                reserved=reserved_map.get(center.id, 0.0),
+            )
+        )
+
+    return centers
+
+
+@router.get("/users/{user_id}/balance", response_model=WalletBalanceResponse)
+async def get_user_balance(
+    user_id: int,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_READ_ALL),
+):
+    """Return a specific user's wallet balance."""
+    await _require_authenticated_user(current_user)
+    user = await _get_user_or_404(db, user_id)
+    return await _get_wallet_balance_read_only(db, user.id)
+
+
+@router.get("/users/{user_id}/transactions", response_model=list[WalletTransactionResponse])
+async def get_user_transactions(
+    user_id: int,
+    limit: int = Query(50, ge=1, le=500),
+    offset: int = Query(0, ge=0),
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_READ_ALL),
+):
+    """Return wallet ledger entries for a specific user."""
+    await _require_authenticated_user(current_user)
+    await _get_user_or_404(db, user_id)
+
+    result = await db.execute(
+        select(WalletTransaction)
+        .where(WalletTransaction.user_id == user_id, WalletTransaction.is_voided.is_(False))
+        .order_by(WalletTransaction.created_at.desc(), WalletTransaction.id.desc())
+        .limit(limit)
+        .offset(offset)
+    )
+    return [_serialize_wallet_transaction(tx) for tx in result.scalars().all()]
+
+
+@router.post("/users/{user_id}/deposit", response_model=WalletAdjustmentResponse)
+async def deposit_user_balance(
+    user_id: int,
+    body: WalletAdjustmentRequest,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
+):
+    """Add funds to a user's wallet."""
+    actor = await _require_authenticated_user(current_user)
+    await _get_user_or_404(db, user_id)
+    return await _create_wallet_adjustment(
+        db,
+        target_user_id=user_id,
+        actor_user_id=actor.id,
+        amount=body.amount,
+        transaction_type=TransactionType.DEPOSIT.value,
+        description=body.description,
+        cost_center_id=body.cost_center_id,
+    )
+
+
+@router.post("/users/{user_id}/withdraw", response_model=WalletAdjustmentResponse)
+async def withdraw_user_balance(
+    user_id: int,
+    body: WalletAdjustmentRequest,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
+):
+    """Withdraw funds from a user's wallet."""
+    actor = await _require_authenticated_user(current_user)
+    await _get_user_or_404(db, user_id)
+    return await _create_wallet_adjustment(
+        db,
+        target_user_id=user_id,
+        actor_user_id=actor.id,
+        amount=-body.amount,
+        transaction_type=TransactionType.WITHDRAW.value,
+        description=body.description,
+        cost_center_id=body.cost_center_id,
+    )
+
+
+@router.post("/rebuild-balance-ledger")
+async def rebuild_balance_ledger(
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
+):
+    """Recompute balance_after for all wallet transactions.
+
+    This rebuilds the running balance for all users and cost centers.
+    - Personal transactions: unassigned plus the user's own private cost center
+    - Cost-center transactions: global running balance for the entire cost center
+    """
+    await _require_authenticated_user(current_user)
+
+    from backend.app.core.database import repair_wallet_ledger_internal
+
+    rebuilt = await repair_wallet_ledger_internal(db)
+
+    return {
+        "status": "success",
+        "transactions_rebuilt": rebuilt,
+        "message": f"Rebuilt {rebuilt} wallet ledger values",
+    }
+
+
+@router.get("/cost-centers", response_model=list[CostCenterSummaryResponse])
+async def list_cost_centers(
+    include_inactive: bool = Query(False),
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = Depends(require_auth_if_enabled),
+):
+    """List all cost centers.
+
+    Requires admin-level finance permissions.
+    """
+    await _require_cost_center_admin_access(current_user)
+    query = select(CostCenter).order_by(CostCenter.is_private.desc(), CostCenter.name.asc())
+    if not include_inactive:
+        query = query.where(CostCenter.is_active.is_(True))
+
+    result = await db.execute(query)
+    centers = result.scalars().all()
+    center_ids = [center.id for center in centers]
+    total_usage_map, month_usage_map = await _get_cost_center_usage_maps(db, center_ids)
+    total_balance_map = await _get_cost_center_balance_map(db, center_ids)
+    reserved_map = await _get_cost_center_reserved_map(db, center_ids)
+
+    return [
+        _to_cost_center_summary(
+            center,
+            can_print=True,
+            total_usage=total_usage_map.get(center.id, 0.0),
+            month_usage=month_usage_map.get(center.id, 0.0),
+            total_balance=total_balance_map.get(center.id, 0.0),
+            reserved=reserved_map.get(center.id, 0.0),
+        )
+        for center in centers
+    ]
+
+
+@router.post("/cost-centers", response_model=CostCenterSummaryResponse)
+async def create_cost_center(
+    body: CostCenterCreateRequest,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_CREATE),
+):
+    """Create a shared cost center."""
+    await _require_authenticated_user(current_user)
+    total_budget = body.total_budget
+    monthly_budget = body.monthly_budget
+    if monthly_budget is not None:
+        total_budget = None
+    elif total_budget is not None:
+        monthly_budget = None
+
+    center = CostCenter(
+        name=body.name.strip(),
+        is_active=body.is_active,
+        is_private=False,
+        owner_user_id=None,
+        total_budget=total_budget,
+        monthly_budget=monthly_budget,
+    )
+    db.add(center)
+    await db.flush()
+    await db.commit()
+    await db.refresh(center)
+
+    return _to_cost_center_summary(center, can_print=True, total_usage=0.0, month_usage=0.0, total_balance=0.0)
+
+
+@router.get("/cost-centers/{cost_center_id}", response_model=CostCenterDetailResponse)
+async def get_cost_center(
+    cost_center_id: int,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = Depends(require_auth_if_enabled),
+):
+    """Get one cost center with its memberships."""
+    await _require_cost_center_admin_access(current_user)
+    center = await _get_cost_center_or_404(db, cost_center_id)
+    total_usage_map, month_usage_map = await _get_cost_center_usage_maps(db, [center.id])
+    total_balance_map = await _get_cost_center_balance_map(db, [center.id])
+    reserved_map = await _get_cost_center_reserved_map(db, [center.id])
+    summary = _to_cost_center_summary(
+        center,
+        can_print=True,
+        total_usage=total_usage_map.get(center.id, 0.0),
+        month_usage=month_usage_map.get(center.id, 0.0),
+        total_balance=total_balance_map.get(center.id, 0.0),
+        reserved=reserved_map.get(center.id, 0.0),
+    )
+    return CostCenterDetailResponse(
+        **summary.model_dump(),
+        members=[CostCenterMemberResponse.model_validate(m) for m in center.members],
+    )
+
+
+@router.patch("/cost-centers/{cost_center_id}", response_model=CostCenterSummaryResponse)
+async def update_cost_center(
+    cost_center_id: int,
+    body: CostCenterUpdateRequest,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
+):
+    """Update name or active-state of a cost center."""
+    await _require_authenticated_user(current_user)
+    center = await _get_cost_center_or_404(db, cost_center_id)
+
+    if center.is_private:
+        raise HTTPException(
+            status_code=400,
+            detail=("Private cost centers cannot be deactivated or renamed; set their budget to 0 to prevent printing"),
+        )
+
+    if body.name is not None:
+        center.name = body.name.strip()
+    if body.is_active is not None:
+        center.is_active = body.is_active
+
+    await db.flush()
+
+    total_usage_map, month_usage_map = await _get_cost_center_usage_maps(db, [center.id])
+    total_balance_map = await _get_cost_center_balance_map(db, [center.id])
+    reserved_map = await _get_cost_center_reserved_map(db, [center.id])
+    return _to_cost_center_summary(
+        center,
+        can_print=True,
+        total_usage=total_usage_map.get(center.id, 0.0),
+        month_usage=month_usage_map.get(center.id, 0.0),
+        total_balance=total_balance_map.get(center.id, 0.0),
+        reserved=reserved_map.get(center.id, 0.0),
+    )
+
+
+@router.patch("/cost-centers/{cost_center_id}/budgets", response_model=CostCenterSummaryResponse)
+async def update_cost_center_budgets(
+    cost_center_id: int,
+    body: CostCenterBudgetUpdateRequest,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
+):
+    """Update budget values of a cost center."""
+    await _require_authenticated_user(current_user)
+    center = await _get_cost_center_or_404(db, cost_center_id)
+
+    if body.monthly_budget is not None:
+        center.monthly_budget = body.monthly_budget
+        center.total_budget = None
+    elif body.total_budget is not None:
+        center.total_budget = body.total_budget
+        center.monthly_budget = None
+    else:
+        center.total_budget = None
+        center.monthly_budget = None
+    await db.flush()
+
+    total_usage_map, month_usage_map = await _get_cost_center_usage_maps(db, [center.id])
+    total_balance_map = await _get_cost_center_balance_map(db, [center.id])
+    reserved_map = await _get_cost_center_reserved_map(db, [center.id])
+    return _to_cost_center_summary(
+        center,
+        can_print=True,
+        total_usage=total_usage_map.get(center.id, 0.0),
+        month_usage=month_usage_map.get(center.id, 0.0),
+        total_balance=total_balance_map.get(center.id, 0.0),
+        reserved=reserved_map.get(center.id, 0.0),
+    )
+
+
+@router.post("/cost-centers/{cost_center_id}/members", response_model=CostCenterMemberResponse)
+async def upsert_cost_center_member(
+    cost_center_id: int,
+    body: CostCenterMemberRequest,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
+):
+    """Assign or update a user's membership on a cost center."""
+    await _require_authenticated_user(current_user)
+    center = await _get_cost_center_or_404(db, cost_center_id)
+
+    if center.is_private:
+        raise HTTPException(status_code=400, detail="Private cost center memberships cannot be modified")
+
+    await _get_user_or_404(db, body.user_id)
+
+    existing = await db.execute(
+        select(CostCenterMember).where(
+            CostCenterMember.cost_center_id == cost_center_id,
+            CostCenterMember.user_id == body.user_id,
+        )
+    )
+    member = existing.scalar_one_or_none()
+    if member is None:
+        member = CostCenterMember(cost_center_id=cost_center_id, user_id=body.user_id, can_print=body.can_print)
+        db.add(member)
+    else:
+        member.can_print = body.can_print
+
+    await db.flush()
+    await db.commit()
+    return CostCenterMemberResponse.model_validate(member)
+
+
+@router.delete("/cost-centers/{cost_center_id}")
+async def delete_cost_center(
+    cost_center_id: int,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
+):
+    """Delete a shared cost center."""
+    await _require_authenticated_user(current_user)
+    center = await _get_cost_center_or_404(db, cost_center_id)
+
+    if center.is_private:
+        raise HTTPException(status_code=400, detail="Private cost centers cannot be deleted")
+
+    transaction_id = await db.scalar(
+        select(WalletTransaction.id)
+        .where(
+            WalletTransaction.cost_center_id == center.id,
+            WalletTransaction.is_voided.is_(False),
+        )
+        .limit(1)
+    )
+    if transaction_id is not None:
+        # ON DELETE SET NULL would turn these shared-center entries into
+        # personal transactions and silently rewrite the affected wallets.
+        raise HTTPException(status_code=400, detail="Cost center cannot be deleted while transactions reference it")
+
+    active_reservation_id = await db.scalar(
+        select(BudgetReservation.id)
+        .where(
+            BudgetReservation.cost_center_id == center.id,
+            BudgetReservation.status == "active",
+        )
+        .limit(1)
+    )
+    if active_reservation_id is not None:
+        raise HTTPException(
+            status_code=400,
+            detail="Cost center cannot be deleted while active budget reservations reference it",
+        )
+
+    await db.delete(center)
+    await db.flush()
+    await db.commit()
+    return {"status": "success"}
+
+
+@router.delete("/cost-centers/{cost_center_id}/members/{user_id}")
+async def remove_cost_center_member(
+    cost_center_id: int,
+    user_id: int,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.COST_CENTERS_MODIFY),
+):
+    """Remove a user from a shared cost center."""
+    await _require_authenticated_user(current_user)
+    center = await _get_cost_center_or_404(db, cost_center_id)
+    if center.is_private:
+        raise HTTPException(status_code=400, detail="Private cost center memberships cannot be modified")
+
+    result = await db.execute(
+        select(CostCenterMember).where(
+            CostCenterMember.cost_center_id == cost_center_id,
+            CostCenterMember.user_id == user_id,
+        )
+    )
+    member = result.scalar_one_or_none()
+    if member is None:
+        raise HTTPException(status_code=404, detail="Membership not found")
+
+    await db.delete(member)
+    await db.commit()
+    return {"status": "success"}

+ 123 - 1
backend/app/api/routes/github_backup.py

@@ -12,6 +12,7 @@ from backend.app.core.permissions import Permission
 from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
 from backend.app.models.user import User
 from backend.app.schemas.github_backup import (
+    REF_PATTERN,
     CloudAccountCounts,
     GitHubBackupConfigCreate,
     GitHubBackupConfigResponse,
@@ -19,10 +20,16 @@ from backend.app.schemas.github_backup import (
     GitHubBackupLogResponse,
     GitHubBackupStatus,
     GitHubBackupTriggerResponse,
+    GitHubCommitListResponse,
+    GitHubRestorePreview,
+    GitHubRestoreRequest,
+    GitHubRestoreResponse,
     GitHubTestConnectionResponse,
     ProviderType,
+    RestoreCategory,
 )
 from backend.app.services.github_backup import github_backup_service
+from backend.app.services.github_restore import github_restore_service
 
 logger = logging.getLogger(__name__)
 
@@ -44,6 +51,33 @@ _UNKNOWN_VISIBILITY_ERROR = (
     "repo API."
 )
 
+# The permission that owns each category's rows, required on top of
+# github:restore. Backup is its own permission group, so without this a role
+# holding only Backup writes — via a restore — rows it cannot write through the
+# endpoint that owns them.
+#
+# Each entry is the permission that endpoint actually gates its writes on:
+#
+#   * SETTINGS   → PUT /api/v1/settings/ (settings:update)
+#   * SPOOLS     → POST/PATCH /api/v1/inventory/spools (inventory:update). Spool
+#     rows and their usage history both restore under this category.
+#   * ARCHIVES   → archives:update_all, not archives:create. A restore writes
+#     rows owned by other users — that is the whole point of carrying
+#     created_by_id — and update_all is the permission that means "may write an
+#     archive that is not yours". create alone would let an operator with
+#     archives:create_own-shaped access seed history onto someone else.
+#   * KPROFILES  → POST /api/v1/printers/{id}/kprofiles (kprofiles:update),
+#     which is what the restore ultimately calls through set_kprofiles_batch.
+#
+# Cloud profiles are absent because they are not a restorable category
+# (RestoreCategory's docstring).
+_CATEGORY_WRITE_PERMISSION = {
+    RestoreCategory.SETTINGS: Permission.SETTINGS_UPDATE,
+    RestoreCategory.SPOOLS: Permission.INVENTORY_UPDATE,
+    RestoreCategory.ARCHIVES: Permission.ARCHIVES_UPDATE_ALL,
+    RestoreCategory.KPROFILES: Permission.KPROFILES_UPDATE,
+}
+
 
 async def _enforce_private_repo(repo_url: str, token: str, provider: str) -> None:
     """Run a test_connection and refuse if the repo is not confirmed private.
@@ -388,13 +422,101 @@ async def get_status(
         configured=True,
         enabled=config.enabled,
         is_running=github_backup_service.is_running,
-        progress=github_backup_service.progress,
+        restore_running=github_restore_service.is_running,
+        progress=github_backup_service.progress or github_restore_service.progress,
         last_backup_at=config.last_backup_at,
         last_backup_status=config.last_backup_status,
         next_scheduled_run=config.next_scheduled_run,
     )
 
 
+@router.get("/commits", response_model=GitHubCommitListResponse)
+async def list_commits(
+    limit: int = Query(default=20, ge=1, le=100),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_RESTORE),
+):
+    """List recent backup commits so the user can pick one to restore from."""
+    result = await db.execute(select(GitHubBackupConfig).limit(1))
+    config = result.scalar_one_or_none()
+
+    if not config:
+        raise HTTPException(status_code=404, detail="No configuration found. Configure backup first.")
+
+    commit_result = await github_restore_service.list_commits(config, limit=limit)
+    return GitHubCommitListResponse(**commit_result)
+
+
+@router.get("/restore/preview", response_model=GitHubRestorePreview)
+async def preview_restore(
+    ref: str = Query(default="HEAD", pattern=REF_PATTERN),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_RESTORE),
+):
+    """Report which categories a given backup commit contains."""
+    result = await db.execute(select(GitHubBackupConfig).limit(1))
+    config = result.scalar_one_or_none()
+
+    if not config:
+        raise HTTPException(status_code=404, detail="No configuration found. Configure backup first.")
+
+    preview = await github_restore_service.preview(db, config, ref=ref)
+    return GitHubRestorePreview(**preview)
+
+
+@router.post("/restore", response_model=GitHubRestoreResponse)
+async def restore_backup(
+    request: GitHubRestoreRequest,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_RESTORE),
+):
+    """Restore selected categories from one backup commit.
+
+    Note there is no private-repo gate here, unlike the config endpoints: that
+    check exists to stop credentials leaving the instance, and this path only
+    reads. A config can only be saved against a private repo anyway.
+
+    Every category needs the permission that owns the rows it writes, on top of
+    ``github:restore`` — see ``_CATEGORY_WRITE_PERMISSION`` and the check below.
+    """
+    if current_user is not None:
+        # Each category rewrites rows some other endpoint already owns, and
+        # Backup is its own permission group — so a role holding only Backup
+        # could otherwise write, through a restore, what it cannot write through
+        # the endpoint that owns them. This module already makes that argument;
+        # it is why the four protected auth keys are refused outright.
+        #
+        # current_user is None only when auth is disabled: github:restore is in
+        # _APIKEY_DENIED_PERMISSIONS, so an API key never gets past the
+        # dependency to reach this line.
+        missing = sorted(
+            {
+                permission.value
+                for category, permission in _CATEGORY_WRITE_PERMISSION.items()
+                if category in request.categories and not current_user.has_all_permissions(permission.value)
+            }
+        )
+        if missing:
+            raise HTTPException(
+                status_code=403,
+                detail=f"Missing required permissions: {', '.join(missing)}",
+            )
+
+    result = await db.execute(select(GitHubBackupConfig).limit(1))
+    config = result.scalar_one_or_none()
+
+    if not config:
+        raise HTTPException(status_code=404, detail="No configuration found. Configure backup first.")
+
+    restore_result = await github_restore_service.run_restore(
+        config.id,
+        ref=request.ref,
+        categories=request.categories,
+        overwrite_existing=request.overwrite_existing,
+    )
+    return GitHubRestoreResponse(**restore_result)
+
+
 @router.get("/logs", response_model=list[GitHubBackupLogResponse])
 async def get_logs(
     limit: int = Query(default=50, ge=1, le=200),

+ 11 - 0
backend/app/api/routes/groups.py

@@ -28,8 +28,19 @@ from backend.app.schemas.group import (
 router = APIRouter(prefix="/groups", tags=["groups"])
 
 
+# Permissions whose derived label would misdescribe what is being granted.
+# The derived form for USERS_READ_SLIM is "Read Slim Users", which reads as a
+# property of the users rather than of the response -- and an admin ticking a
+# box in the group editor has nothing else to go on (#1894).
+_PERMISSION_LABEL_OVERRIDES: dict[Permission, str] = {
+    Permission.USERS_READ_SLIM: "List User Names (id + username only)",
+}
+
+
 def _permission_label(perm: Permission) -> str:
     """Convert permission enum to human-readable label."""
+    if perm in _PERMISSION_LABEL_OVERRIDES:
+        return _PERMISSION_LABEL_OVERRIDES[perm]
     # e.g., "printers:read" -> "Read Printers"
     parts = perm.value.split(":")
     if len(parts) == 2:

+ 239 - 0
backend/app/api/routes/ha_sensors.py

@@ -0,0 +1,239 @@
+"""API routes for Home Assistant sensors bound to a printer (#1148, #448)."""
+
+import logging
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.printer import Printer
+from backend.app.models.printer_ha_sensor import PrinterHASensor
+from backend.app.models.user import User
+from backend.app.schemas.printer_ha_sensor import (
+    HADisplayEntity,
+    PrinterHASensorCreate,
+    PrinterHASensorReading,
+    PrinterHASensorResponse,
+    PrinterHASensorUpdate,
+)
+from backend.app.services.ha_sensor_manager import ha_sensor_manager
+from backend.app.services.homeassistant import homeassistant_service
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/ha-sensors", tags=["ha-sensors"])
+
+# These reuse the smart-plug permissions rather than introducing their own.
+# Both surfaces are "the Home Assistant integration", and a brand-new
+# permission would be missing from every existing custom role — users who can
+# manage plugs today would silently lose access to the sensors next to them.
+_READ = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ)
+_CREATE = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CREATE)
+_UPDATE = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_UPDATE)
+_DELETE = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_DELETE)
+
+
+async def _refresh_quietly(sensor: PrinterHASensor, db: AsyncSession) -> None:
+    """Take a first reading without letting it fail the write that preceded it.
+
+    The sensor row is committed before this runs. A failure here costs the card
+    one poll interval of blank state, which is not worth turning a successful
+    save into an error response.
+    """
+    try:
+        await ha_sensor_manager.refresh_one(db, sensor)
+    except Exception as e:
+        logger.warning("Could not read %s right after saving it: %s", sensor.entity_id, e)
+
+
+@router.get("/", response_model=list[PrinterHASensorResponse])
+async def list_ha_sensors(
+    printer_id: int | None = None,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = _READ,
+):
+    """List configured sensors, grouped by printer and in display order."""
+    query = select(PrinterHASensor)
+    if printer_id is not None:
+        query = query.where(PrinterHASensor.printer_id == printer_id)
+    result = await db.execute(query.order_by(PrinterHASensor.printer_id, PrinterHASensor.sort_order))
+    return list(result.scalars().all())
+
+
+# Must precede /{sensor_id} so "entities" is not parsed as an id.
+@router.get("/entities", response_model=list[HADisplayEntity])
+async def list_bindable_entities(
+    search: str | None = None,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = _READ,
+):
+    """List the Home Assistant entities that can be bound to a printer."""
+    from backend.app.api.routes.settings import get_homeassistant_settings
+
+    ha_settings = await get_homeassistant_settings(db)
+    if not ha_settings["ha_url"] or not ha_settings["ha_token"]:
+        raise HTTPException(
+            400,
+            "Home Assistant not configured. Please set HA URL and token in Settings → Network → Home Assistant.",
+        )
+
+    entities = await homeassistant_service.list_display_entities(ha_settings["ha_url"], ha_settings["ha_token"], search)
+    return [HADisplayEntity(**e) for e in entities]
+
+
+@router.get("/by-printer/{printer_id}/readings", response_model=list[PrinterHASensorReading])
+async def get_printer_sensor_readings(
+    printer_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = _READ,
+):
+    """Live state of a printer's card-visible sensors.
+
+    Served from the poller's cache, so a page full of printer cards costs
+    Home Assistant nothing. A sensor the poller has not reached yet falls back
+    to its last persisted state, marked unreachable, rather than vanishing
+    from the card on every restart.
+    """
+    result = await db.execute(
+        select(PrinterHASensor)
+        .where(
+            PrinterHASensor.printer_id == printer_id,
+            PrinterHASensor.show_on_printer_card.is_(True),
+        )
+        .order_by(PrinterHASensor.sort_order, PrinterHASensor.id)
+    )
+
+    readings = []
+    for sensor in result.scalars().all():
+        cached = ha_sensor_manager.get_reading(sensor.id)
+        readings.append(
+            PrinterHASensorReading(
+                id=sensor.id,
+                name=sensor.name,
+                entity_id=sensor.entity_id,
+                kind=sensor.kind,
+                device_class=sensor.device_class,
+                unit=sensor.unit,
+                state=cached.state if cached else sensor.last_state,
+                value=cached.value if cached else None,
+                alerting=cached.alerting if cached else False,
+                block_print=sensor.block_print,
+                reachable=cached.reachable if cached else False,
+                last_changed=sensor.last_changed,
+            )
+        )
+    return readings
+
+
+@router.post("/", response_model=PrinterHASensorResponse)
+async def create_ha_sensor(
+    data: PrinterHASensorCreate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = _CREATE,
+):
+    """Bind a Home Assistant entity to a printer."""
+    printer = await db.get(Printer, data.printer_id)
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    existing = await db.execute(
+        select(PrinterHASensor).where(
+            PrinterHASensor.printer_id == data.printer_id,
+            PrinterHASensor.entity_id == data.entity_id,
+        )
+    )
+    if existing.scalar_one_or_none():
+        raise HTTPException(400, f"{data.entity_id} is already bound to this printer")
+
+    sensor = PrinterHASensor(**data.model_dump())
+    db.add(sensor)
+    await db.commit()
+    await db.refresh(sensor)
+    logger.info("Bound HA entity %s to printer %s as '%s'", sensor.entity_id, sensor.printer_id, sensor.name)
+
+    # Read it once now so the card shows a state immediately instead of after
+    # the next poll tick. Best-effort: the row is already committed, so letting
+    # a Home Assistant hiccup 500 the request would report a failure for work
+    # that succeeded — and the retry would come back "already bound".
+    await _refresh_quietly(sensor, db)
+    return sensor
+
+
+@router.get("/{sensor_id}", response_model=PrinterHASensorResponse)
+async def get_ha_sensor(
+    sensor_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = _READ,
+):
+    sensor = await db.get(PrinterHASensor, sensor_id)
+    if not sensor:
+        raise HTTPException(404, "Sensor not found")
+    return sensor
+
+
+@router.patch("/{sensor_id}", response_model=PrinterHASensorResponse)
+async def update_ha_sensor(
+    sensor_id: int,
+    data: PrinterHASensorUpdate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = _UPDATE,
+):
+    sensor = await db.get(PrinterHASensor, sensor_id)
+    if not sensor:
+        raise HTTPException(404, "Sensor not found")
+
+    updates = data.model_dump(exclude_unset=True)
+
+    # Re-run the create-time rules against the merged row. A PATCH that only
+    # sets block_print has no entity_id or alert_state in its payload, so the
+    # schema alone cannot tell whether the result is coherent.
+    merged = {field: getattr(sensor, field) for field in PrinterHASensorCreate.model_fields}
+    merged.update(updates)
+    try:
+        PrinterHASensorCreate(**merged)
+    except ValueError as e:
+        raise HTTPException(422, str(e)) from e
+
+    # Same uniqueness rule as create: repointing a sensor at an entity the
+    # printer already has would leave two rows fighting over one pill.
+    new_entity = updates.get("entity_id")
+    if new_entity and new_entity != sensor.entity_id:
+        clash = await db.execute(
+            select(PrinterHASensor).where(
+                PrinterHASensor.printer_id == sensor.printer_id,
+                PrinterHASensor.entity_id == new_entity,
+                PrinterHASensor.id != sensor.id,
+            )
+        )
+        if clash.scalar_one_or_none():
+            raise HTTPException(400, f"{new_entity} is already bound to this printer")
+
+    for field, value in updates.items():
+        setattr(sensor, field, value)
+    await db.commit()
+    await db.refresh(sensor)
+
+    # The entity or its alert rule may have changed under the cached reading.
+    await _refresh_quietly(sensor, db)
+    return sensor
+
+
+@router.delete("/{sensor_id}")
+async def delete_ha_sensor(
+    sensor_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = _DELETE,
+):
+    sensor = await db.get(PrinterHASensor, sensor_id)
+    if not sensor:
+        raise HTTPException(404, "Sensor not found")
+
+    name = sensor.name
+    await db.delete(sensor)
+    await db.commit()
+    ha_sensor_manager.forget(sensor_id)
+    logger.info("Removed HA sensor '%s'", name)
+    return {"message": f"Sensor '{name}' removed"}

+ 413 - 69
backend/app/api/routes/library.py

@@ -69,14 +69,26 @@ from backend.app.services.design_settings import (
     extract_design_process_overrides,
     overrides_from_config,
 )
+from backend.app.services.filament_requirements import annotate_rack_groups
 from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
+from backend.app.services.process_overrides import apply_process_overrides
+from backend.app.services.slice_output_check import missing_start_gcode_message, start_gcode_is_missing
 from backend.app.services.stl_thumbnail import MIN_USABLE_STL_BYTES, generate_stl_thumbnail
-from backend.app.utils.filename import InvalidFilenameError, validate_print_filename
+from backend.app.utils.filename import (
+    MAX_FILENAME_BYTES,
+    InvalidFilenameError,
+    safe_path_component,
+    validate_print_filename,
+)
+from backend.app.utils.safe_path import PathTraversalError, assert_under, safe_join_under
 from backend.app.utils.threemf_tools import (
+    default_plate_gcode_name,
     expand_to_project_slots,
     extract_embedded_presets_from_3mf,
     extract_nozzle_mapping_from_3mf,
     extract_project_filaments_from_3mf,
+    select_plate_gcode_name,
+    supports_enabled_in_config,
 )
 
 logger = logging.getLogger(__name__)
@@ -291,6 +303,112 @@ def _resolve_upload_destination(target_folder: LibraryFolder | None, filename: s
     return get_library_files_dir() / f"{uuid.uuid4().hex}{ext}", False
 
 
+def _unique_external_name(ext_dir: Path, filename: str) -> str:
+    """Return ``filename``, or the first free ``<stem> (n)<suffix>`` variant.
+
+    Splits on the *compound* extension so re-slicing ``Bidoof.3mf`` yields
+    ``Bidoof (2).gcode.3mf`` rather than ``Bidoof.gcode (2).3mf``.
+
+    Uploads answer a name collision with a 409, which is right for a file the
+    user just chose to send. A slice is not that: re-slicing the same source
+    with different settings is routine, and the second run has already spent
+    minutes of CPU by the time the name is known -- refusing to store it would
+    throw that away. Overwriting is worse still, since the target is somebody's
+    NAS and the file being replaced may not even be ours.
+    """
+    stem = filename[: -len(".gcode.3mf")] if filename.endswith(".gcode.3mf") else Path(filename).stem
+    suffix = ".gcode.3mf" if filename.endswith(".gcode.3mf") else Path(filename).suffix
+    candidate = filename
+    counter = 2
+    # Bounded: a directory holding 999 re-slices of one model is pathological,
+    # and an unbounded loop here would hang the request on a mount that lies
+    # about exists() (some SMB shares do under contention).
+    #
+    # safe_join_under rather than `ext_dir / candidate`: `filename` derives
+    # from a name read out of a 3MF, so the very first probe must not be able
+    # to stat its way outside the mount. It raises PathTraversalError, which
+    # the caller turns into a managed-storage fallback.
+    while safe_join_under(ext_dir, candidate, http=False).exists() and counter < 1000:
+        candidate = f"{stem} ({counter}){suffix}"
+        counter += 1
+    return candidate
+
+
+def _resolve_slice_destination(target_folder: LibraryFolder | None, out_filename: str) -> tuple[Path, bool, str | None]:
+    """Resolve where a slice result should be written.
+
+    Returns ``(path, is_external, fallback_reason)``. ``fallback_reason`` is
+    ``None`` on the normal paths and otherwise names why an external folder
+    could not receive the file, so the caller can tell the user instead of
+    quietly filing it elsewhere.
+
+    Slicing a file that lives on an external mount used to store the output in
+    the managed library dir unconditionally, while giving the new row the
+    external folder's ``folder_id`` (#2810). The file therefore appeared in the
+    right folder in the UI and never arrived on the share, which is the one
+    place the user was looking -- and made it un-reproducible from the web UI
+    alone. Uploads learned this in #1112 (``_resolve_upload_destination``) and
+    moves in its follow-up (``_move_file_bytes``); slicing was the last write
+    path still assuming managed storage.
+
+    Unlike uploads, a failure here does not raise. The bytes exist and cost
+    real time to produce, so an unwritable target falls back to managed storage
+    with a reason attached rather than discarding the slice.
+    """
+    if target_folder is None or not target_folder.is_external:
+        return get_library_files_dir() / f"{uuid.uuid4().hex}.gcode.3mf", False, None
+
+    if target_folder.external_readonly:
+        return get_library_files_dir() / f"{uuid.uuid4().hex}.gcode.3mf", False, "external_readonly"
+    if not target_folder.external_path:
+        return get_library_files_dir() / f"{uuid.uuid4().hex}.gcode.3mf", False, "external_no_path"
+
+    ext_dir = Path(target_folder.external_path)
+    if not ext_dir.exists() or not ext_dir.is_dir():
+        return get_library_files_dir() / f"{uuid.uuid4().hex}.gcode.3mf", False, "external_unreachable"
+    if not os.access(ext_dir, os.W_OK):
+        return get_library_files_dir() / f"{uuid.uuid4().hex}.gcode.3mf", False, "external_not_writable"
+
+    try:
+        dest = safe_join_under(ext_dir, _unique_external_name(ext_dir, out_filename), http=False)
+    except PathTraversalError:
+        # The source filename reached us from a 3MF on disk, so this is
+        # defensive rather than expected -- but a name that escapes the mount
+        # must land in managed storage, never outside it.
+        return get_library_files_dir() / f"{uuid.uuid4().hex}.gcode.3mf", False, "external_invalid_name"
+    return dest, True, None
+
+
+async def _folder_tree_file_ids(db: AsyncSession, folder_id: int) -> list[int]:
+    """Every ``LibraryFile`` id under ``folder_id``, at any depth.
+
+    Deleting a folder cascades to its whole subtree, so anything that has to be
+    released before that delete (queue items, cross-model candidates) needs the
+    subtree, not just the folder's own files.
+
+    Trashed rows are included deliberately: they are still real rows and the
+    cascade takes them too.
+    """
+    file_ids: list[int] = []
+    pending = [folder_id]
+    # The API refuses to make a folder its own ancestor, so a loop here would
+    # mean the table is already corrupt -- but this walk runs inside a delete
+    # request, and hanging one is worse than the cost of a set.
+    seen: set[int] = set()
+    while pending:
+        current = pending.pop()
+        if current in seen:
+            continue
+        seen.add(current)
+        file_ids.extend(
+            (await db.execute(select(LibraryFile.id).where(LibraryFile.folder_id == current))).scalars().all()
+        )
+        pending.extend(
+            (await db.execute(select(LibraryFolder.id).where(LibraryFolder.parent_id == current))).scalars().all()
+        )
+    return file_ids
+
+
 def _stored_file_path(abs_path: Path, is_external: bool) -> str:
     """Produce the value to persist in ``LibraryFile.file_path``.
 
@@ -1342,7 +1460,16 @@ async def delete_folder(
 
         return file_ids
 
-    await get_all_file_ids(folder_id)
+    doomed_file_ids = await get_all_file_ids(folder_id)
+
+    # The folder cascade hard-deletes every file row under it, so the queue has
+    # to be taken off them first — same as the single-file delete below (#2819).
+    # The return value used to be discarded here, which is why this never
+    # happened for a folder delete.
+    from backend.app.services.library_trash import delete_dependent_variants, release_queue_references
+
+    await delete_dependent_variants(db, doomed_file_ids)
+    await release_queue_references(db, doomed_file_ids)
 
     # Delete folder (cascade will handle files and subfolders)
     await db.delete(folder)
@@ -2023,6 +2150,20 @@ async def list_files(
             )
             hash_counts = {h: c - 1 for h, c in dup_result.all()}  # -1 to exclude self
 
+    # Variant group sizes (#671 / #2570). Counted across the whole group rather
+    # than the rows on screen — members can sit in different folders, so counting
+    # the listing would under-report and the "2 versions" badge would blink in
+    # and out as the user navigated.
+    variant_counts: dict[int, int] = {}
+    group_ids = {f.variant_group_id for f in files if f.variant_group_id}
+    if group_ids:
+        count_result = await db.execute(
+            select(LibraryFile.variant_group_id, func.count(LibraryFile.id))
+            .where(LibraryFile.variant_group_id.in_(group_ids), LibraryFile.deleted_at.is_(None))
+            .group_by(LibraryFile.variant_group_id)
+        )
+        variant_counts = dict(count_result.all())
+
     # Prevent browser caching of file list
     response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
 
@@ -2059,6 +2200,8 @@ async def list_files(
                 filament_used_grams=filament_grams,
                 sliced_for_model=sliced_for_model,
                 tags=[TagSummary(id=t.id, name=t.name) for t in f.tags],
+                variant_group_id=f.variant_group_id,
+                variant_count=variant_counts.get(f.variant_group_id, 0) if f.variant_group_id else 0,
             )
         )
 
@@ -2654,7 +2797,7 @@ def is_sliced_file(filename: str) -> bool:
 async def add_files_to_queue(
     request: AddToQueueRequest,
     db: AsyncSession = Depends(get_db),
-    _: User | None = Depends(require_permission_if_auth_enabled(Permission.QUEUE_CREATE)),
+    current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.QUEUE_CREATE)),
 ):
     """Add library files to the print queue.
 
@@ -2720,6 +2863,10 @@ async def add_files_to_queue(
                 or (folder_projects.get(lib_file.folder_id) if lib_file.folder_id is not None else None),
                 position=max_position,
                 status="pending",
+                # Without this the row is ownerless, and `queue:read_own` filters
+                # on `created_by_id` — so the user who queued the file could not
+                # see it in their own queue.
+                created_by_id=current_user.id if current_user else None,
             )
             db.add(queue_item)
 
@@ -3294,6 +3441,11 @@ async def get_library_file_filament_requirements(
                 for filament in filaments:
                     filament["nozzle_id"] = nozzle_mapping.get(filament["slot_id"])
 
+            # Nozzle-rack machines (#1784): the print dialog offers a rack
+            # position per filament group, which needs the group table as well
+            # as the carriage above.
+            annotate_rack_groups(filaments, file_path, plate_id)
+
     except Exception as e:
         logger.warning("Failed to parse filament requirements from library file %s: %s", file_id, e)
 
@@ -3483,6 +3635,16 @@ _SOURCE_PROCESS_SUPPORT_KEYS_TO_PRESERVE = (
 def _patch_process_support_settings(process_json: str, source_3mf_bytes: bytes) -> str:
     """Overlay the source 3MF's support configuration onto the process JSON.
 
+    The carry is deliberately one-way: a source can switch supports *on*,
+    never off (#2820). The original #1881 rule was "source wins in both
+    directions", which quietly stripped supports from every custom process
+    preset that enabled them — a MakerWorld download nearly always ships
+    `enable_support: 0`, so the reporter's own preset (supports on, normal
+    (auto)) came back out of the slicer disabled and set to tree(auto).
+    Nothing is lost by not carrying the off direction: a process preset
+    with supports *on* is by definition a deliberate user preset, since
+    Bambu's shipped ones all ship them off.
+
     Only fires on 3MF sources — STL / STEP don't carry `project_settings.
     config`. Silently no-ops when the source doesn't have the config, has
     a malformed one, or when the process JSON isn't parseable — the slice
@@ -3500,6 +3662,8 @@ def _patch_process_support_settings(process_json: str, source_3mf_bytes: bytes)
         return process_json
     if not isinstance(src_cfg, dict):
         return process_json
+    if not supports_enabled_in_config(src_cfg):
+        return process_json
 
     try:
         process_cfg = json.loads(process_json)
@@ -3508,9 +3672,15 @@ def _patch_process_support_settings(process_json: str, source_3mf_bytes: bytes)
     if not isinstance(process_cfg, dict):
         return process_json
 
-    for key in _SOURCE_PROCESS_SUPPORT_KEYS_TO_PRESERVE:
-        if key in src_cfg:
-            process_cfg[key] = src_cfg[key]
+    carried = {key: src_cfg[key] for key in _SOURCE_PROCESS_SUPPORT_KEYS_TO_PRESERVE if key in src_cfg}
+    process_cfg.update(carried)
+    # Logged because this is the one layer of the process JSON the user
+    # can't see coming: the slice modal shows the picked preset's values,
+    # so a carried key silently disagrees with what was on screen.
+    logger.info(
+        "Carried support settings from the source 3MF onto the process preset: %s",
+        dict(sorted(carried.items())),
+    )
 
     return json.dumps(process_cfg)
 
@@ -3714,6 +3884,14 @@ async def _run_slicer_with_fallback(
                 request.design_overrides,
             )
 
+    # The user's own edits from the slice modal's settings panel. Applied last
+    # and for every model type (not just 3MF): unlike the two patches above this
+    # doesn't read anything out of the source file, it is what the user typed.
+    # Last write wins, so an explicit choice beats both the carried support
+    # config (#1881) and the designer's tweaks (#2622).
+    if request.process_overrides:
+        presets["process"] = apply_process_overrides(presets["process"], request.process_overrides)
+
     used_embedded_settings = False
     # "Slice as designed" (#2611): honour the file's embedded
     # project_settings.config instead of the picked profile triplet. Only
@@ -3756,6 +3934,13 @@ async def _run_slicer_with_fallback(
                 target_model,
             )
             cross_class_arrange = True
+
+    # #2548: the user can also ask for either layout pass per-slice. Arrange
+    # is a union with the cross-class decision above — a user opt-out must
+    # not be able to switch off the flag that keeps a class-crossing slice
+    # from crashing — while orient is user-driven only.
+    arrange_flag = cross_class_arrange or request.auto_arrange
+    orient_flag = request.auto_orient
     # When this slice is dispatcher-tracked, generate a request_id so
     # the sidecar publishes progress under it, and wire a callback that
     # forwards each frame onto SliceDispatchService.set_progress for the
@@ -3805,36 +3990,27 @@ async def _run_slicer_with_fallback(
 
         filament_jsons = substitute_unused_plate_filaments(primary_bytes, request.plate or 1, filament_jsons)
 
-    # Cross-class slice-all loop (#1493): when the user asks for
-    # ``plate=0`` (all plates) AND the source's nozzle class differs from
-    # the target's, ``--slice 0 --arrange 1`` consolidates every plate's
-    # objects onto a single target bed (BS's ``--arrange`` is project-
-    # wide) — either packing them all together or rejecting with "Some
-    # objects are located over the boundary of the heated bed" when
-    # nothing fits. Slice each plate independently with ``--arrange 1``
-    # and merge the per-plate outputs into one multi-plate 3MF instead.
-    # Same-class slice-all goes through the regular path below — the
-    # sidecar's native ``--slice 0`` produces the right shape directly.
-    use_cross_class_slice_all = cross_class_arrange and request.plate == 0 and request.export_3mf
+    # Arrange slice-all loop (#1493): when the user asks for ``plate=0``
+    # (all plates) AND arrange is on, ``--slice 0 --arrange 1``
+    # consolidates every plate's objects onto a single target bed (BS's
+    # ``--arrange`` is project-wide) — either packing them all together or
+    # rejecting with "Some objects are located over the boundary of the
+    # heated bed" when nothing fits. Slice each plate independently with
+    # ``--arrange 1`` and merge the per-plate outputs into one multi-plate
+    # 3MF instead. Slice-all without arrange goes through the regular path
+    # below — the sidecar's native ``--slice 0`` produces the right shape
+    # directly.
+    #
+    # Keyed on ``arrange_flag``, not just the cross-class decision: the
+    # project-wide collapse is a property of ``--arrange`` itself, so a
+    # user-requested arrange over all plates (#2548) hits it identically.
+    # Orient doesn't — it rotates objects where they stand and never moves
+    # one between plates — so it isn't part of this condition.
+    use_arrange_slice_all = arrange_flag and request.plate == 0 and request.export_3mf
 
     try:
         try:
-            if embedded_mode:
-                # No --load-settings: feed the CLI the file's own
-                # project_settings.config untouched so the designer's tweaks
-                # (walls, infill, etc.) drive the slice. primary_bytes is
-                # already sentinel-sanitised above, the same bytes the
-                # crash-fallback uses. The resolved presets go unused here.
-                result = await service.slice_without_profiles(
-                    model_bytes=primary_bytes,
-                    model_filename=model_filename,
-                    plate=request.plate,
-                    export_3mf=request.export_3mf,
-                    request_id=progress_request_id,
-                    on_progress=progress_callback,
-                )
-                used_embedded_settings = True
-            elif use_cross_class_slice_all:
+            if use_arrange_slice_all:
                 from backend.app.services.slicer_3mf_convert import (
                     count_plates_in_3mf,
                     merge_plate_3mfs,
@@ -3851,8 +4027,10 @@ async def _run_slicer_with_fallback(
                         ),
                     )
                 logger.info(
-                    "Cross-class slice-all: looping over %d plates with --arrange per plate, then merging",
+                    "Arrange slice-all: looping over %d plates with --arrange per plate, then merging "
+                    "(embedded_settings=%s)",
                     plate_count,
+                    embedded_mode,
                 )
                 from backend.app.services.slicer_api import SliceResult
 
@@ -3881,18 +4059,35 @@ async def _run_slicer_with_fallback(
 
                 for plate_num in range(1, plate_count + 1):
                     plate_cb = _wrap_progress_for_plate(plate_num, plate_count)
-                    per_plate = await service.slice_with_profiles(
-                        model_bytes=primary_bytes,
-                        model_filename=model_filename,
-                        printer_profile_json=presets["printer"],
-                        process_profile_json=presets["process"],
-                        filament_profile_jsons=filament_jsons,
-                        plate=plate_num,
-                        export_3mf=True,
-                        arrange=True,
-                        request_id=progress_request_id,
-                        on_progress=plate_cb,
-                    )
+                    # "Slice as designed" has to take the loop too, not skip
+                    # it: the project-wide collapse is caused by --arrange,
+                    # and which config drives the slice has no bearing on
+                    # that. Same call, minus --load-settings.
+                    if embedded_mode:
+                        per_plate = await service.slice_without_profiles(
+                            model_bytes=primary_bytes,
+                            model_filename=model_filename,
+                            plate=plate_num,
+                            export_3mf=True,
+                            arrange=True,
+                            orient=orient_flag,
+                            request_id=progress_request_id,
+                            on_progress=plate_cb,
+                        )
+                    else:
+                        per_plate = await service.slice_with_profiles(
+                            model_bytes=primary_bytes,
+                            model_filename=model_filename,
+                            printer_profile_json=presets["printer"],
+                            process_profile_json=presets["process"],
+                            filament_profile_jsons=filament_jsons,
+                            plate=plate_num,
+                            export_3mf=True,
+                            arrange=True,
+                            orient=orient_flag,
+                            request_id=progress_request_id,
+                            on_progress=plate_cb,
+                        )
                     per_plate_results.append((plate_num, per_plate))
 
                 # Merge the N single-plate 3MFs into one multi-plate 3MF.
@@ -3913,6 +4108,28 @@ async def _run_slicer_with_fallback(
                     filament_used_g=sum(r.filament_used_g for _, r in per_plate_results),
                     filament_used_mm=sum(r.filament_used_mm for _, r in per_plate_results),
                 )
+                # Report the path honestly: the loop can run either way, and
+                # the UI reads this flag to tell the user whose settings won.
+                used_embedded_settings = embedded_mode
+            elif embedded_mode:
+                # No --load-settings: feed the CLI the file's own
+                # project_settings.config untouched so the designer's tweaks
+                # (walls, infill, etc.) drive the slice. primary_bytes is
+                # already sentinel-sanitised above, the same bytes the
+                # crash-fallback uses. The resolved presets go unused here.
+                # Arrange / orient still apply: they are CLI actions on the
+                # geometry, not settings the embedded config could carry.
+                result = await service.slice_without_profiles(
+                    model_bytes=primary_bytes,
+                    model_filename=model_filename,
+                    plate=request.plate,
+                    export_3mf=request.export_3mf,
+                    arrange=arrange_flag,
+                    orient=orient_flag,
+                    request_id=progress_request_id,
+                    on_progress=progress_callback,
+                )
+                used_embedded_settings = True
             else:
                 result = await service.slice_with_profiles(
                     model_bytes=primary_bytes,
@@ -3922,7 +4139,8 @@ async def _run_slicer_with_fallback(
                     filament_profile_jsons=filament_jsons,
                     plate=request.plate,
                     export_3mf=request.export_3mf,
-                    arrange=cross_class_arrange,
+                    arrange=arrange_flag,
+                    orient=orient_flag,
                     request_id=progress_request_id,
                     on_progress=progress_callback,
                 )
@@ -3942,6 +4160,14 @@ async def _run_slicer_with_fallback(
                 # error (the outer handler turns it into a 502) instead of
                 # re-running the same embedded slice.
                 raise
+            if use_arrange_slice_all:
+                # The fallback is a single ``--slice 0`` call, and with
+                # arrange on that collapses every plate onto one bed — the
+                # exact outcome the per-plate loop above exists to avoid.
+                # Retrying would hand back a one-plate result for a job the
+                # user asked to slice as N, which reads as a Bambuddy bug
+                # rather than a slicer failure. Surface the error instead.
+                raise
             logger.warning(
                 "Slicer CLI failed on the --load-settings path for %s (%s); retrying with embedded settings",
                 model_filename,
@@ -3955,11 +4181,17 @@ async def _run_slicer_with_fallback(
             # there too, so without sanitisation the fallback would die
             # on the same sentinel error (#1201). The SliceModal flags
             # the difference to the user via used_embedded_settings.
+            # Carry the layout flags across too — the retry is meant to
+            # differ from the failed attempt only in where the print
+            # config came from, so dropping them here would silently
+            # produce an un-arranged result the user did ask for.
             result = await service.slice_without_profiles(
                 model_bytes=primary_bytes,
                 model_filename=model_filename,
                 plate=request.plate,
                 export_3mf=request.export_3mf,
+                arrange=arrange_flag,
+                orient=orient_flag,
                 request_id=progress_request_id,
                 on_progress=progress_callback,
             )
@@ -3979,6 +4211,26 @@ async def _run_slicer_with_fallback(
     finally:
         await service.close()
 
+    # Backstop for #2838. Only the standard tier, and only when the presets we
+    # sent were actually used: there the sidecar resolved a bundled preset by
+    # name and the bundle guarantees the start G-code, so its absence is a
+    # sidecar defect we can name. A cloud, local or Orca-cloud preset carries
+    # its own start G-code, and the embedded-settings fallback prints the
+    # source file's — both are the user's to author, and refusing them here
+    # would be us second-guessing a profile we did not resolve.
+    if (
+        not used_embedded_settings
+        and request.printer_preset is not None
+        and request.printer_preset.source == "standard"
+        and start_gcode_is_missing(result.content, export_3mf=bool(request.export_3mf))
+    ):
+        logger.error(
+            "Slice for printer preset %r came back without start G-code (%s); refusing it",
+            request.printer_preset.id,
+            "3mf" if request.export_3mf else "gcode",
+        )
+        raise HTTPException(status_code=502, detail=missing_start_gcode_message(request.printer_preset.id))
+
     return result, used_embedded_settings
 
 
@@ -4077,10 +4329,33 @@ async def slice_and_persist(
         job_id=job_id,
     )
 
+    # Same reduction as the archive sink: ``model_filename`` may be built from
+    # the source's embedded ``print_name``, which is free text (#2832). Managed
+    # storage names the file after a UUID and never sees this, but an external
+    # folder writes it verbatim, where a "/" would mean a directory nobody
+    # created -- and the library row shows it either way.
     base_name = model_filename.rsplit(".", 1)[0]
-    out_filename = f"{base_name}.gcode.3mf"
-    unique_name = f"{uuid.uuid4().hex}.gcode.3mf"
-    out_path = get_library_files_dir() / unique_name  # SEC-PATH-OK: unique_name = uuid.uuid4().hex + ".gcode.3mf"
+    safe_base = safe_path_component(base_name, fallback="sliced", max_bytes=MAX_FILENAME_BYTES - len(b".gcode.3mf"))
+    out_filename = f"{safe_base}.gcode.3mf"
+    # Write next to the source when the source lives on an external mount
+    # (#2810). The folder is loaded here rather than passed in because every
+    # caller already has only the id.
+    target_folder: LibraryFolder | None = None
+    if folder_id is not None:
+        folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
+        target_folder = folder_result.scalar_one_or_none()
+    out_path, out_is_external, external_fallback = _resolve_slice_destination(target_folder, out_filename)
+    if out_is_external:
+        # _unique_external_name may have suffixed it; the library row has to
+        # show the name the file actually has on the share, or the two drift.
+        out_filename = out_path.name
+    if external_fallback:
+        logger.warning(
+            "Slice output for %s stored in managed library instead of external folder %s: %s",
+            model_filename,
+            target_folder.external_path if target_folder else None,
+            external_fallback,
+        )
     # BS/Orca CLIs skip plate_N.png in headless --export-3mf — render +
     # inject server-side so the library card has a thumbnail. Best-effort:
     # no-op when the slicer did embed thumbs (desktop Studio path), and
@@ -4128,13 +4403,16 @@ async def slice_and_persist(
     )
     if used_embedded_settings:
         metadata["used_embedded_settings"] = True
+    if external_fallback:
+        metadata["external_write_fallback"] = external_fallback
     if extra_metadata:
         metadata.update(extra_metadata)
 
     new_file = LibraryFile(
         folder_id=folder_id,
+        is_external=out_is_external,
         filename=out_filename,
-        file_path=to_relative_path(out_path),
+        file_path=_stored_file_path(out_path, out_is_external),
         # The on-disk payload is a ZIP container — the file_type must
         # record that so the preview endpoint opens it as a 3MF instead
         # of returning the ZIP bytes as text/plain (#1709 / yanglei1980).
@@ -4162,6 +4440,7 @@ async def slice_and_persist(
         filament_used_g=filament_g,
         filament_used_mm=filament_mm,
         used_embedded_settings=used_embedded_settings,
+        external_write_fallback=external_fallback,
     )
 
 
@@ -4198,19 +4477,33 @@ async def slice_and_persist_as_archive(
         current_user_id=current_user_id,
     )
 
-    base_name = model_filename.rsplit(".", 1)[0]
-    out_filename = f"{base_name}.gcode.3mf"
-
     timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
     printer_folder = str(source_archive.printer_id) if source_archive.printer_id is not None else "unassigned"
-    archive_subdir = f"{timestamp}_{base_name}_sliced"
+
+    # ``model_filename`` is built from the archive's display name, which comes
+    # from the 3MF's own metadata and is whatever the model's author typed. A
+    # "/" in it is a path separator, not a character: the joins below silently
+    # gain a level and the write lands on a parent that was never created
+    # (#2832). Reduce it to a single component first, leaving room for the
+    # prefix and the extension wrapped around it.
+    base_name = model_filename.rsplit(".", 1)[0]
+    reserve = max(len(f"{timestamp}__sliced".encode()), len(b".gcode.3mf"))
+    safe_base = safe_path_component(
+        base_name, fallback=f"archive_{source_archive.id}", max_bytes=MAX_FILENAME_BYTES - reserve
+    )
+    out_filename = f"{safe_base}.gcode.3mf"
+    archive_subdir = f"{timestamp}_{safe_base}_sliced"
+
     archive_dir = (
         app_settings.archive_dir / printer_folder / archive_subdir
-    )  # SEC-PATH-OK: printer_folder = str(int|None), archive_subdir = f"{timestamp}_{base_name}_sliced" where base_name went through _safe_filename
+    )  # SEC-PATH-OK: printer_folder = str(int|None); archive_subdir wraps safe_path_component output, asserted below
+    out_path = archive_dir / out_filename  # SEC-PATH-OK: out_filename wraps safe_path_component output, asserted below
+    # The sanitiser is what makes the two joins single-component; this is the
+    # backstop that says so out loud, and would catch a future edit that reaches
+    # around it. Checked before mkdir so a rejected path creates nothing.
+    assert_under(app_settings.archive_dir, archive_dir, http=False)
+    assert_under(app_settings.archive_dir, out_path, http=False)
     archive_dir.mkdir(parents=True, exist_ok=True)
-    out_path = (
-        archive_dir / out_filename
-    )  # SEC-PATH-OK: out_filename = f"{base_name}.gcode.3mf" where base_name went through _safe_filename
     # See library-slice path: BS/Orca sidecar CLIs don't embed plate_N.png
     # in headless --export-3mf, so the produced 3MF often has no thumbnail
     # at all. Server-side render fills the gap; no-op when the slicer did
@@ -4381,13 +4674,23 @@ async def slice_library_file(
     lib_file = _ensure_library_file_visible(lib_file, current_user, can_read_all)
 
     src_lower = (lib_file.filename or "").lower()
-    if not (
-        src_lower.endswith(".stl")
-        or src_lower.endswith(".3mf")
-        or src_lower.endswith(".step")
-        or src_lower.endswith(".stp")
-    ):
-        raise HTTPException(status_code=400, detail="Source file must be STL, 3MF, or STEP")
+    if src_lower.endswith(".step") or src_lower.endswith(".stp"):
+        # Neither slicer's CLI can load STEP: OrcaSlicer 2.4.2 and BambuStudio
+        # 02.07.01.62 both answer "Unknown file format. Input file must have
+        # .stl, .obj, .amf(.xml) extension." Accepting the job here meant
+        # reading the file, converting it and uploading it before the sidecar
+        # rejected it as unparseable -- which reads as a corrupt model rather
+        # than an unsupported format. Say so before any of that happens.
+        raise HTTPException(
+            status_code=400,
+            detail=(
+                "STEP files cannot be sliced. The OrcaSlicer and Bambu Studio command-line "
+                "slicers load only STL and 3MF -- open the STEP in your slicer and export it "
+                "as one of those first."
+            ),
+        )
+    if not (src_lower.endswith(".stl") or src_lower.endswith(".3mf")):
+        raise HTTPException(status_code=400, detail="Source file must be STL or 3MF")
 
     src_path = Path(app_settings.base_dir) / lib_file.file_path
     if not src_path.exists():
@@ -4692,6 +4995,10 @@ async def delete_file(
                 abs_thumb_path.unlink()
             except OSError as e:
                 logger.warning("Failed to delete thumbnail from disk: %s", e)
+        from backend.app.services.library_trash import delete_dependent_variants, release_queue_references
+
+        await delete_dependent_variants(db, [file.id])
+        await release_queue_references(db, [file.id])
         await db.delete(file)
         await db.commit()
         return {"status": "success", "message": "File deleted", "trashed": False}
@@ -4826,6 +5133,7 @@ async def get_thumbnail(
 @router.get("/files/{file_id}/gcode")
 async def get_gcode(
     file_id: int,
+    plate: int | None = None,
     db: AsyncSession = Depends(get_db),
     auth_result: tuple[User | None, bool] = Depends(
         require_ownership_permission(
@@ -4834,7 +5142,15 @@ async def get_gcode(
         )
     ),
 ):
-    """Get gcode for a file (for preview)."""
+    """Get gcode for a file (for preview).
+
+    Mirrors the archive route: ``?plate=2`` returns ``Metadata/plate_2.gcode``,
+    and omitting it returns the lowest-numbered plate. The viewer has been
+    sending ``plate`` since it gained a multi-plate URL, but this route took no
+    such parameter and FastAPI drops unknown query parameters silently — so
+    every multi-plate library file opened on whichever plate the slicer wrote
+    first into the zip, which is not plate 1.
+    """
     user, can_read_all = auth_result
     result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
     file = _ensure_library_file_visible(result.scalar_one_or_none(), user, can_read_all)
@@ -4848,13 +5164,22 @@ async def get_gcode(
     # case, so detect by suffix before checking the type column.
     is_gcode_3mf = file.file_type in ("3mf", "gcode.3mf") or file.filename.lower().endswith(".gcode.3mf")
 
+    if plate is not None and plate < 1:
+        raise HTTPException(status_code=400, detail="Plate index must be >= 1")
+
     if is_gcode_3mf:
         try:
             with zipfile.ZipFile(str(abs_path), "r") as zf:
                 gcode_files = [n for n in zf.namelist() if n.endswith(".gcode")]
                 if not gcode_files:
                     raise HTTPException(status_code=404, detail="No gcode found in 3MF file")
-                gcode_content = zf.read(gcode_files[0])
+                if plate is not None:
+                    selected = select_plate_gcode_name(gcode_files, plate)
+                    if selected is None:
+                        raise HTTPException(status_code=404, detail=f"Plate {plate} not found in this file")
+                else:
+                    selected = default_plate_gcode_name(gcode_files)
+                gcode_content = zf.read(selected)
                 from fastapi.responses import Response
 
                 return Response(content=gcode_content, media_type="text/plain")
@@ -4991,10 +5316,15 @@ async def bulk_delete(
 
     Files not owned by the user are skipped (unless user has *_all permission).
     """
+    from backend.app.services.library_trash import delete_dependent_variants, release_queue_references
+
     user, can_modify_all = auth_result
     deleted_files = 0
     deleted_folders = 0
     skipped_files = 0
+    # External files bypass the trash and are removed for good, so the queue has
+    # to come off them. Collected here and dealt with once, below the loop.
+    hard_deleted: list[LibraryFile] = []
 
     # Delete files first. Managed files go to trash (sweeper hard-deletes bytes
     # later); external files bypass trash since their disk state is outside our
@@ -5016,11 +5346,22 @@ async def bulk_delete(
                     abs_thumb_path.unlink()
                 except OSError as e:
                     logger.warning("Failed to delete thumbnail from disk: %s", e)
-            await db.delete(file)
+            hard_deleted.append(file)
         else:
             file.deleted_at = now
         deleted_files += 1
 
+    # After the loop and before any delete is issued (#2819). Order matters
+    # twice over: a query run while a delete is pending autoflushes it, taking
+    # the cascade with it, and releasing once for the whole set is a couple of
+    # statements rather than a couple per file.
+    if hard_deleted:
+        hard_deleted_ids = [f.id for f in hard_deleted]
+        await delete_dependent_variants(db, hard_deleted_ids)
+        await release_queue_references(db, hard_deleted_ids)
+        for file in hard_deleted:
+            await db.delete(file)
+
     # Delete folders (cascade will handle contents). Folders have no ownership
     # tracking, so users without *_all permission may only delete empty,
     # non-external, non-linked folders (#1781) — same rule as DELETE /folders/{id}.
@@ -5038,6 +5379,9 @@ async def bulk_delete(
                 )
             )
             deleted_files += file_count_result.scalar() or 0
+            tree_file_ids = await _folder_tree_file_ids(db, folder_id)
+            await delete_dependent_variants(db, tree_file_ids)
+            await release_queue_references(db, tree_file_ids)
             await db.delete(folder)
             deleted_folders += 1
 

+ 425 - 0
backend/app/api/routes/library_variants.py

@@ -0,0 +1,425 @@
+"""Variant groups — one job, several sliced files (#671 / #2570).
+
+A user with more than one printer model slices the same job once per model. The
+files are unrelated as far as the library is concerned: different names,
+different metadata, often uploaded separately after being sliced in Bambu Studio.
+A variant group is the user telling Bambuddy that they are interchangeable.
+
+Two features consume that statement from opposite ends:
+
+* the print queue picks the printer and needs the matching file (#671)
+* the File Manager's print action has the printer already and needs the same
+  match (#2570)
+
+The group itself stores no model information. Each member's target model comes
+from its own ``sliced_for_model``, parsed out of the 3MF, so a group can never
+disagree with the files in it. A legacy file that declares no model may name one
+explicitly, because there is nothing else to go on.
+
+Invariants enforced here rather than in the database, because they are about
+meaning rather than shape:
+
+* **Two members minimum.** A group of one expresses no choice. Removing members
+  down to one dissolves the group rather than leaving a stub that does nothing.
+* **One member per model.** Two files sliced for the same printer are not
+  alternatives — the resolver would have no basis to prefer one, so an
+  arbitrary pick would look like a bug the first time the wrong quality preset
+  came out.
+* **Members must be sliced and must resolve to a model.** An unsliced .3mf can
+  never be dispatched, so it cannot be a candidate.
+* **A file belongs to at most one group**, which the schema already guarantees;
+  this layer turns the resulting overwrite into an explicit 409.
+
+Permissions follow library_tags.py: mutations need LIBRARY_UPDATE_ALL /
+LIBRARY_UPDATE_OWN, reads need LIBRARY_READ_ALL / LIBRARY_READ_OWN, and an
+``*_OWN`` caller only ever sees or touches files they created.
+"""
+
+from __future__ import annotations
+
+import logging
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import require_ownership_permission
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.library import FileVariantGroup, LibraryFile
+from backend.app.models.user import User
+from backend.app.schemas.library import (
+    VariantGroupCreate,
+    VariantGroupMemberRequest,
+    VariantGroupMemberResponse,
+    VariantGroupResponse,
+    VariantGroupUpdate,
+)
+from backend.app.utils.printer_models import normalize_printer_model, normalize_printer_model_id
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/library/variant-groups", tags=["library-variants"])
+
+# File types that can actually be sent to a printer. A source .3mf or an .stl
+# has no G-code and no sliced_for_model, so it is never a dispatch candidate.
+_PRINTABLE_TYPES = ("gcode.3mf", "gcode")
+
+
+def normalize_model_name(raw: str | None) -> str | None:
+    """Normalize any spelling of a printer model to its short name.
+
+    Internal codes are resolved **first**. ``normalize_printer_model`` returns
+    unknown input unchanged rather than None, so an ``x or y`` chain in the other
+    order never reaches the code map and leaves "O1C" as "O1C" — which then
+    matches no printer row and leaves the job waiting forever. Running the code
+    map first is a no-op for every non-code input.
+    """
+    if not raw:
+        return None
+    return normalize_printer_model(normalize_printer_model_id(raw) or raw) or raw
+
+
+def resolve_variant_model(lib_file: LibraryFile, explicit: str | None = None) -> str | None:
+    """Normalized model a file will be dispatched to, or None if unknowable.
+
+    Precedence: the caller's explicit choice for this request, then the durable
+    override stored on the file, then what the 3MF itself declares. The override
+    exists because a file imported before Bambuddy parsed ``sliced_for_model``
+    declares nothing, and without a way to say so it could never be grouped.
+    It is kept separate from ``file_metadata`` so a user's assertion is never
+    mistaken for something parsed out of the file.
+    """
+    raw = explicit or lib_file.variant_target_model or (lib_file.file_metadata or {}).get("sliced_for_model")
+    return normalize_model_name(raw)
+
+
+async def _load_files(
+    db: AsyncSession,
+    file_ids: list[int],
+    user: User | None,
+    can_access_all: bool,
+) -> dict[int, LibraryFile]:
+    """Fetch the caller's visible, untrashed files by id."""
+    query = LibraryFile.active().where(LibraryFile.id.in_(file_ids))
+    if user is not None and not can_access_all:
+        query = query.where(LibraryFile.created_by_id == user.id)
+    rows = (await db.execute(query)).scalars().all()
+    return {f.id: f for f in rows}
+
+
+def _validate_member(lib_file: LibraryFile, explicit_model: str | None) -> str:
+    """Return the member's model, or raise the reason it cannot be one."""
+    if lib_file.file_type not in _PRINTABLE_TYPES:
+        raise HTTPException(
+            400,
+            f"{lib_file.filename} is not a sliced file — only sliced output can be a print variant",
+        )
+    model = resolve_variant_model(lib_file, explicit_model)
+    if not model:
+        raise HTTPException(
+            400,
+            f"{lib_file.filename} does not say which printer it was sliced for — set its target model explicitly",
+        )
+    if explicit_model:
+        # Persist the user's answer, normalized. The group stores no model data
+        # of its own, so without this the choice would last exactly one request
+        # and the member would read back with no model at all.
+        lib_file.variant_target_model = model
+    return model
+
+
+async def _group_response(db: AsyncSession, group: FileVariantGroup) -> VariantGroupResponse:
+    members = (
+        (
+            await db.execute(
+                LibraryFile.active()
+                .where(LibraryFile.variant_group_id == group.id)
+                .order_by(LibraryFile.variant_position, LibraryFile.id)
+            )
+        )
+        .scalars()
+        .all()
+    )
+    return VariantGroupResponse(
+        id=group.id,
+        name=group.name,
+        members=[
+            VariantGroupMemberResponse(
+                library_file_id=f.id,
+                filename=f.filename,
+                # Members were validated on the way in, but a file whose metadata
+                # was rewritten since then should not blow up a read.
+                target_model=resolve_variant_model(f) or "",
+                position=f.variant_position,
+            )
+            for f in members
+        ],
+    )
+
+
+async def _get_group_or_404(db: AsyncSession, group_id: int) -> FileVariantGroup:
+    group = (await db.execute(select(FileVariantGroup).where(FileVariantGroup.id == group_id))).scalar_one_or_none()
+    if not group:
+        raise HTTPException(404, "Variant group not found")
+    return group
+
+
+async def _dissolve_if_too_small(db: AsyncSession, group: FileVariantGroup) -> bool:
+    """Delete the group when fewer than two members remain.
+
+    A one-member group is not a choice, and leaving one behind would let the
+    queue create a cross-model item with a single candidate that silently
+    behaves like an ordinary job. Returns True when the group was dissolved.
+    """
+    remaining = (await db.execute(select(LibraryFile).where(LibraryFile.variant_group_id == group.id))).scalars().all()
+    if len(remaining) >= 2:
+        return False
+    for lib_file in remaining:
+        lib_file.variant_group_id = None
+        lib_file.variant_position = 0
+    await db.delete(group)
+    return True
+
+
+@router.post("", response_model=VariantGroupResponse, status_code=201)
+@router.post("/", response_model=VariantGroupResponse, status_code=201)
+async def create_variant_group(
+    payload: VariantGroupCreate,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_UPDATE_ALL,
+            Permission.LIBRARY_UPDATE_OWN,
+        )
+    ),
+) -> VariantGroupResponse:
+    """Group files as variants of one job, in priority order."""
+    user, can_update_all = auth_result
+
+    file_ids = [m.library_file_id for m in payload.members]
+    if len(set(file_ids)) != len(file_ids):
+        raise HTTPException(400, "The same file cannot appear twice in a variant group")
+
+    files = await _load_files(db, file_ids, user, can_update_all)
+    missing = [fid for fid in file_ids if fid not in files]
+    if missing:
+        raise HTTPException(404, f"Library file not found: {missing[0]}")
+
+    already_grouped = [files[fid].filename for fid in file_ids if files[fid].variant_group_id is not None]
+    if already_grouped:
+        raise HTTPException(409, f"{already_grouped[0]} already belongs to a variant group")
+
+    models: dict[str, str] = {}
+    for member in payload.members:
+        lib_file = files[member.library_file_id]
+        model = _validate_member(lib_file, member.target_model)
+        if model in models:
+            raise HTTPException(
+                400,
+                f"{lib_file.filename} and {models[model]} are both sliced for {model} — "
+                "variants must target different printers",
+            )
+        models[model] = lib_file.filename
+
+    group = FileVariantGroup(
+        name=payload.name or files[file_ids[0]].filename,
+        created_by_id=user.id if user else None,
+    )
+    db.add(group)
+    await db.flush()
+
+    for position, fid in enumerate(file_ids):
+        files[fid].variant_group_id = group.id
+        files[fid].variant_position = position
+
+    await db.commit()
+    logger.info("Created variant group %s with %d members", group.id, len(file_ids))
+    return await _group_response(db, group)
+
+
+@router.get("/by-file/{file_id}", response_model=VariantGroupResponse)
+async def get_group_for_file(
+    file_id: int,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_READ_ALL,
+            Permission.LIBRARY_READ_OWN,
+        )
+    ),
+) -> VariantGroupResponse:
+    """The group a file belongs to.
+
+    Both consumers start from a file rather than a group id: the print modal
+    knows which file the user clicked, and the queue-create flow knows which
+    file was selected.
+    """
+    user, can_read_all = auth_result
+    files = await _load_files(db, [file_id], user, can_read_all)
+    lib_file = files.get(file_id)
+    if not lib_file:
+        raise HTTPException(404, "Library file not found")
+    if lib_file.variant_group_id is None:
+        raise HTTPException(404, "File is not part of a variant group")
+    return await _group_response(db, await _get_group_or_404(db, lib_file.variant_group_id))
+
+
+@router.get("/{group_id}", response_model=VariantGroupResponse)
+async def get_variant_group(
+    group_id: int,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_READ_ALL,
+            Permission.LIBRARY_READ_OWN,
+        )
+    ),
+) -> VariantGroupResponse:
+    return await _group_response(db, await _get_group_or_404(db, group_id))
+
+
+@router.patch("/{group_id}", response_model=VariantGroupResponse)
+async def update_variant_group(
+    group_id: int,
+    payload: VariantGroupUpdate,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_UPDATE_ALL,
+            Permission.LIBRARY_UPDATE_OWN,
+        )
+    ),
+) -> VariantGroupResponse:
+    """Rename the group, re-order its members, or both.
+
+    Re-ordering is how the user says which printer they would rather have when
+    both are free, so it must be an explicit full ordering — a partial list
+    would leave the rest in an order nobody chose.
+    """
+    user, can_update_all = auth_result
+    group = await _get_group_or_404(db, group_id)
+
+    if payload.name is not None:
+        group.name = payload.name
+
+    if payload.member_file_ids is not None:
+        current = (
+            (await db.execute(select(LibraryFile).where(LibraryFile.variant_group_id == group.id))).scalars().all()
+        )
+        if set(payload.member_file_ids) != {f.id for f in current}:
+            raise HTTPException(400, "member_file_ids must list exactly the group's current members")
+        files = await _load_files(db, payload.member_file_ids, user, can_update_all)
+        if len(files) != len(payload.member_file_ids):
+            raise HTTPException(404, "Library file not found")
+        for position, fid in enumerate(payload.member_file_ids):
+            files[fid].variant_position = position
+
+    await db.commit()
+    return await _group_response(db, group)
+
+
+@router.post("/{group_id}/members", response_model=VariantGroupResponse)
+async def add_variant_group_member(
+    payload: VariantGroupMemberRequest,
+    group_id: int,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_UPDATE_ALL,
+            Permission.LIBRARY_UPDATE_OWN,
+        )
+    ),
+) -> VariantGroupResponse:
+    """Attach another slice to an existing group.
+
+    This is the common real case: the H2S version was queued last week, the H2C
+    version was sliced today.
+    """
+    user, can_update_all = auth_result
+    group = await _get_group_or_404(db, group_id)
+
+    files = await _load_files(db, [payload.library_file_id], user, can_update_all)
+    lib_file = files.get(payload.library_file_id)
+    if not lib_file:
+        raise HTTPException(404, "Library file not found")
+    if lib_file.variant_group_id == group.id:
+        raise HTTPException(409, f"{lib_file.filename} is already in this group")
+    if lib_file.variant_group_id is not None:
+        raise HTTPException(409, f"{lib_file.filename} already belongs to a variant group")
+
+    model = _validate_member(lib_file, payload.target_model)
+
+    existing = (
+        (
+            await db.execute(
+                LibraryFile.active()
+                .where(LibraryFile.variant_group_id == group.id)
+                .order_by(LibraryFile.variant_position, LibraryFile.id)
+            )
+        )
+        .scalars()
+        .all()
+    )
+    for other in existing:
+        if resolve_variant_model(other) == model:
+            raise HTTPException(
+                400,
+                f"{lib_file.filename} and {other.filename} are both sliced for {model} — "
+                "variants must target different printers",
+            )
+
+    lib_file.variant_group_id = group.id
+    lib_file.variant_position = len(existing)
+    await db.commit()
+    return await _group_response(db, group)
+
+
+@router.delete("/{group_id}/members/{file_id}", response_model=None, status_code=204)
+async def remove_variant_group_member(
+    group_id: int,
+    file_id: int,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_UPDATE_ALL,
+            Permission.LIBRARY_UPDATE_OWN,
+        )
+    ),
+) -> None:
+    """Drop one file out of a group; the file itself is untouched."""
+    user, can_update_all = auth_result
+    group = await _get_group_or_404(db, group_id)
+
+    files = await _load_files(db, [file_id], user, can_update_all)
+    lib_file = files.get(file_id)
+    if not lib_file or lib_file.variant_group_id != group.id:
+        raise HTTPException(404, "File is not a member of this group")
+
+    lib_file.variant_group_id = None
+    lib_file.variant_position = 0
+    await db.flush()
+    await _dissolve_if_too_small(db, group)
+    await db.commit()
+
+
+@router.delete("/{group_id}", response_model=None, status_code=204)
+async def delete_variant_group(
+    group_id: int,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_UPDATE_ALL,
+            Permission.LIBRARY_UPDATE_OWN,
+        )
+    ),
+) -> None:
+    """Ungroup the files. The files themselves are kept — every one of them is
+    independently printable, which is the whole reason they were grouped."""
+    group = await _get_group_or_404(db, group_id)
+    members = (await db.execute(select(LibraryFile).where(LibraryFile.variant_group_id == group.id))).scalars().all()
+    for lib_file in members:
+        lib_file.variant_group_id = None
+        lib_file.variant_position = 0
+    await db.delete(group)
+    await db.commit()

+ 1 - 0
backend/app/api/routes/notification_templates.py

@@ -30,6 +30,7 @@ EVENT_NAMES = {
     "print_failed": "Print Failed",
     "print_stopped": "Print Stopped",
     "print_progress": "Print Progress",
+    "billing_charge_failed": "Billing Charge Failed",
     "printer_offline": "Printer Offline",
     "printer_error": "Printer Error",
     "filament_low": "Filament Low",

+ 4 - 0
backend/app/api/routes/notifications.py

@@ -44,6 +44,7 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
         "on_print_stopped": provider.on_print_stopped,
         "on_print_progress": provider.on_print_progress,
         "on_print_missing_spool_assignment": provider.on_print_missing_spool_assignment,
+        "on_billing_charge_failed": provider.on_billing_charge_failed,
         # Printer status events
         "on_printer_offline": provider.on_printer_offline,
         "on_printer_error": provider.on_printer_error,
@@ -53,6 +54,7 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
         # AMS environmental alarms (regular AMS)
         "on_ams_humidity_high": provider.on_ams_humidity_high,
         "on_ams_temperature_high": provider.on_ams_temperature_high,
+        "on_ams_drying_suspended": provider.on_ams_drying_suspended,
         # AMS-HT environmental alarms
         "on_ams_ht_humidity_high": provider.on_ams_ht_humidity_high,
         "on_ams_ht_temperature_high": provider.on_ams_ht_temperature_high,
@@ -126,6 +128,7 @@ async def create_notification_provider(
         on_print_stopped=provider_data.on_print_stopped,
         on_print_progress=provider_data.on_print_progress,
         on_print_missing_spool_assignment=provider_data.on_print_missing_spool_assignment,
+        on_billing_charge_failed=provider_data.on_billing_charge_failed,
         # Printer status events
         on_printer_offline=provider_data.on_printer_offline,
         on_printer_error=provider_data.on_printer_error,
@@ -135,6 +138,7 @@ async def create_notification_provider(
         # AMS environmental alarms (regular AMS)
         on_ams_humidity_high=provider_data.on_ams_humidity_high,
         on_ams_temperature_high=provider_data.on_ams_temperature_high,
+        on_ams_drying_suspended=provider_data.on_ams_drying_suspended,
         # AMS-HT environmental alarms
         on_ams_ht_humidity_high=provider_data.on_ams_ht_humidity_high,
         on_ams_ht_temperature_high=provider_data.on_ams_ht_temperature_high,

+ 28 - 5
backend/app/api/routes/pipeline_runs.py

@@ -33,6 +33,7 @@ from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import delete, desc, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.api.routes.cloud import resolve_api_key_cloud_owner
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import async_session, get_db
@@ -663,12 +664,16 @@ async def run_pipeline(
     pipeline_id: int,
     body: PipelineRunCreateRequest,
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
+    api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
     db: AsyncSession = Depends(get_db),
 ):
     from backend.app.api.routes.settings import get_setting
     from backend.app.services.slice_dispatch import slice_dispatch
 
     pipeline = await _load_pipeline(db, pipeline_id)
+    # ``user=current_user`` deliberately, not the cloud owner below: an API-key
+    # caller has no per-row identity and must keep can_read_all, the same as
+    # every other read helper.
     src_kind, src_id, src_filename, src_path = await _resolve_source(
         db,
         library_file_id=body.source_library_file_id,
@@ -676,6 +681,14 @@ async def run_pipeline(
         user=current_user,
     )
 
+    # The permission gate answers an API-keyed request with current_user=None,
+    # so a pipeline built on Bambu/Orca Cloud presets would have nobody whose
+    # stored cloud token could resolve them. Fall back to the key's owner, the
+    # same fallback POST /library/files/{id}/slice makes (#1182 follow-up).
+    # Only keys with the cloud scope resolve to an owner here; everything else
+    # stays None and slices against local presets exactly as before.
+    creator = current_user or api_key_cloud_owner
+
     # Cap copies against the configured ceiling.
     raw_cap = await get_setting(db, "pipeline_max_copies")
     try:
@@ -712,7 +725,7 @@ async def run_pipeline(
         copies=body.copies,
         status="queued",
         eligibility_overridden=(not report.ok and body.force),
-        created_by=current_user.id if current_user else None,
+        created_by=creator.id if creator else None,
     )
     db.add(run)
     await db.flush()
@@ -737,14 +750,14 @@ async def run_pipeline(
         src_id=src_id,
         src_filename=src_filename,
         src_path=src_path,
-        creator_user_id=current_user.id if current_user else None,
+        creator_user_id=creator.id if creator else None,
         copies=body.copies,
     )
     slice_job = await slice_dispatch.enqueue(
         kind="library_file" if src_kind == "library_file" else "archive",
         source_id=src_id,
         source_name=src_filename,
-        owner_id=current_user.id if current_user else None,
+        owner_id=creator.id if creator else None,
         run=orchestrate,
     )
 
@@ -915,6 +928,7 @@ async def cancel_run(
 async def retry_failed(
     run_id: int,
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
+    api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
     db: AsyncSession = Depends(get_db),
 ):
     """Create a new run with copies = (failed + cancelled count) from the
@@ -955,8 +969,17 @@ async def retry_failed(
     )
 
     # Reuse the run_pipeline route logic via a direct call — keeps the
-    # orchestration single-sourced. The result inherits parent_run_id.
-    new_run_response = await run_pipeline(parent.pipeline_id, body, current_user=current_user, db=db)
+    # orchestration single-sourced. The result inherits parent_run_id. Every
+    # dependency it declares has to be forwarded explicitly: FastAPI resolves
+    # those only for a routed request, so an omitted one would arrive as the
+    # Depends() marker object itself rather than as None.
+    new_run_response = await run_pipeline(
+        parent.pipeline_id,
+        body,
+        current_user=current_user,
+        api_key_cloud_owner=api_key_cloud_owner,
+        db=db,
+    )
 
     # Stamp parent_run_id on the freshly-created run.
     new_row = (await db.execute(select(PipelineRun).where(PipelineRun.id == new_run_response.id))).scalar_one_or_none()

+ 57 - 47
backend/app/api/routes/print_log.py

@@ -3,7 +3,7 @@ from datetime import datetime
 
 from fastapi import APIRouter, Depends, HTTPException, Query
 from fastapi.responses import FileResponse
-from sqlalchemy import delete, func, select
+from sqlalchemy import delete, func, nullslast, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.auth import (
@@ -22,6 +22,30 @@ logger = logging.getLogger(__name__)
 
 router = APIRouter(prefix="/print-log", tags=["print-log"])
 
+# Sortable columns, keyed by the id the Print Log table uses for its columns
+# (#2636). An explicit map rather than getattr on a caller-supplied string:
+# the client picks the key, so anything else would let a request order by any
+# attribute it can name.
+#
+# ``date`` coalesces because the column renders ``started_at or created_at`` —
+# sorting on started_at alone would scatter the rows that have no start time
+# (queue-skipped entries) instead of interleaving them where the user sees
+# them.
+_SORTABLE_COLUMNS = {
+    "date": func.coalesce(PrintLogEntry.started_at, PrintLogEntry.created_at),
+    "print_name": PrintLogEntry.print_name,
+    "printer": PrintLogEntry.printer_name,
+    "user": PrintLogEntry.created_by_username,
+    "status": PrintLogEntry.status,
+    "duration": PrintLogEntry.duration_seconds,
+    "completed_at": PrintLogEntry.completed_at,
+    "filament": PrintLogEntry.filament_type,
+    "filament_used": PrintLogEntry.filament_used_grams,
+    "cost": PrintLogEntry.cost,
+    "energy": PrintLogEntry.energy_kwh,
+    "energy_cost": PrintLogEntry.energy_cost,
+}
+
 
 @router.get("/", response_model=PrintLogResponse)
 async def get_print_log(
@@ -33,6 +57,8 @@ async def get_print_log(
     date_to: datetime | None = None,
     limit: int = Query(default=50, ge=1, le=500),
     offset: int = Query(default=0, ge=0),
+    sort_by: str = Query(default="date"),
+    sort_dir: str = Query(default="desc", pattern="^(asc|desc)$"),
     db: AsyncSession = Depends(get_db),
     auth_result: tuple[User | None, bool] = Depends(
         require_ownership_permission(
@@ -72,37 +98,36 @@ async def get_print_log(
     total_result = await db.execute(count_query)
     total = total_result.scalar() or 0
 
-    # Get paginated results
-    query = query.order_by(PrintLogEntry.created_at.desc()).offset(offset).limit(limit)
+    # Sorting happens here rather than in the browser because the table is
+    # paginated server-side: ordering the 25 rows the client happens to hold
+    # would answer "the most expensive print on this page", which is not what
+    # clicking a column header means.
+    sort_column = _SORTABLE_COLUMNS.get(sort_by)
+    if sort_column is None:
+        raise HTTPException(400, f"Cannot sort by {sort_by!r}")
+    ordering = sort_column.asc() if sort_dir == "asc" else sort_column.desc()
+    # NULLs last in both directions, so a column that is empty for half the
+    # rows (cost before a spool is priced, energy without a smart plug) never
+    # buries the rows that do have values. Left to the database this differs
+    # per backend — Postgres sorts NULLs high, SQLite sorts them low — so the
+    # same click would give two different first pages depending on deployment.
+    query = query.order_by(nullslast(ordering), PrintLogEntry.id.desc())
+    # id.desc() above is the tiebreaker: without it, rows sharing a value
+    # (every "completed" when sorting by status) come back in whatever order
+    # the planner picks, which can differ between pages and duplicate or drop
+    # a row as the user pages through.
+    query = query.offset(offset).limit(limit)
     result = await db.execute(query)
     entries = result.scalars().all()
 
+    # Validate straight off the ORM rows rather than naming each field: the
+    # hand-written version dropped whatever it forgot to mention, and a
+    # forgotten field is indistinguishable from a NULL column on the wire.
+    # It lost failure_reason that way (#1687 part 4), then cost / energy_kwh /
+    # energy_cost, which were written to the table but never sent — so the
+    # Print Log's cost and energy columns read empty for every run (#2636).
     return PrintLogResponse(
-        items=[
-            PrintLogEntrySchema(
-                id=e.id,
-                archive_id=e.archive_id,
-                print_name=e.print_name,
-                printer_name=e.printer_name,
-                printer_id=e.printer_id,
-                status=e.status,
-                started_at=e.started_at,
-                completed_at=e.completed_at,
-                duration_seconds=e.duration_seconds,
-                filament_type=e.filament_type,
-                filament_color=e.filament_color,
-                filament_used_grams=e.filament_used_grams,
-                # failure_reason was silently dropped by the GET serialiser
-                # before #1687 part 4 — without it the Print Log table couldn't
-                # surface what the Failure Analysis widget already groups by.
-                failure_reason=e.failure_reason,
-                thumbnail_path=e.thumbnail_path,
-                created_by_id=e.created_by_id,
-                created_by_username=e.created_by_username,
-                created_at=e.created_at,
-            )
-            for e in entries
-        ],
+        items=[PrintLogEntrySchema.model_validate(e) for e in entries],
         total=total,
     )
 
@@ -285,22 +310,7 @@ async def update_print_log_entry(
         entry.status,
     )
 
-    return PrintLogEntrySchema(
-        id=entry.id,
-        archive_id=entry.archive_id,
-        print_name=entry.print_name,
-        printer_name=entry.printer_name,
-        printer_id=entry.printer_id,
-        status=entry.status,
-        started_at=entry.started_at,
-        completed_at=entry.completed_at,
-        duration_seconds=entry.duration_seconds,
-        filament_type=entry.filament_type,
-        filament_color=entry.filament_color,
-        filament_used_grams=entry.filament_used_grams,
-        failure_reason=entry.failure_reason,
-        thumbnail_path=entry.thumbnail_path,
-        created_by_id=entry.created_by_id,
-        created_by_username=entry.created_by_username,
-        created_at=entry.created_at,
-    )
+    # Same field-by-field trap as the list route: this one also omitted cost
+    # and the energy pair, so the row the client merged back after an edit
+    # blanked whichever columns it was showing for them.
+    return PrintLogEntrySchema.model_validate(entry)

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 729 - 75
backend/app/api/routes/print_queue.py


+ 69 - 18
backend/app/api/routes/printers.py

@@ -46,12 +46,15 @@ from backend.app.services.bambu_ftp import (
     delete_file_async,
     download_file_bytes_async,
     download_file_try_paths_async,
+    ftps_handshake_blocked,
     get_cached_3mf,
     get_storage_info_async,
     list_files_async,
 )
+from backend.app.services.print_storage import print_file_reachable_over_ftp
 from backend.app.services.printer_diagnostic import run_connection_diagnostic
 from backend.app.services.printer_manager import (
+    display_temperatures,
     drying_screen_only,
     get_derived_status_name,
     printer_manager,
@@ -61,10 +64,11 @@ from backend.app.services.printer_manager import (
     supports_chamber_temp,
     supports_drying,
     supports_drying_while_printing,
+    uniform_tray_filament_hint,
 )
 from backend.app.utils.filament_ids import filament_id_to_setting_id
 from backend.app.utils.http import build_content_disposition
-from backend.app.utils.printer_models import uses_exhaust_fan_label
+from backend.app.utils.printer_models import MAX_CHAMBER_TEMP_C, uses_exhaust_fan_label
 
 logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/printers", tags=["printers"])
@@ -576,20 +580,12 @@ async def get_printer_status(
                     dry_target_temp = None
             if target_fil_val:
                 dry_filament = str(target_fil_val)
-            # Fallback: derive from first loaded tray when no cached target
-            # (drying started in a previous backend session, or cache wasn't
-            # seeded). Mirrors the popover seed heuristic.
-            if dry_target_temp is None or not dry_filament:
-                for tray in trays:
-                    if tray.tray_type:
-                        if not dry_filament:
-                            dry_filament = str(tray.tray_type)
-                        if dry_target_temp is None and tray.drying_temp:
-                            try:
-                                dry_target_temp = int(tray.drying_temp)
-                            except (TypeError, ValueError):
-                                pass
-                        break
+            # Fallback: name the filament from the loaded trays when there is no
+            # cached target (drying started in a previous backend session, or
+            # the cache wasn't seeded), and only when they agree. The
+            # temperature has no fallback — see uniform_tray_filament_hint.
+            if not dry_filament:
+                dry_filament = uniform_tray_filament_hint([tray.tray_type or "" for tray in trays])
 
             ams_units.append(
                 AMSUnit(
@@ -869,6 +865,7 @@ async def get_overlay_status(
             "layer_num": None,
             "total_layers": None,
             "stg_cur_name": None,
+            "temperatures": {},
             "time_format": time_format,
         }
 
@@ -885,6 +882,9 @@ async def get_overlay_status(
         "layer_num": state.layer_num,
         "total_layers": state.total_layers,
         "stg_cur_name": get_derived_status_name(state, printer.model),
+        # Nozzle / bed / chamber readings for the overlay's temperature fields
+        # (#1422). Filtered rather than passed through: see display_temperatures.
+        "temperatures": display_temperatures(state.temperatures, printer.model),
         "time_format": time_format,
     }
 
@@ -1218,6 +1218,19 @@ async def _produce_cover_image(
             break
 
     if not downloaded:
+        # The cover lives inside the 3MF, so it is only reachable if the 3MF is.
+        # When the printer kept the print on internal storage there is nothing
+        # at any of these paths, and walking all sixteen of them just to end on
+        # a 404 that reads as "this print has no cover" helps nobody (#2780).
+        storage = print_file_reachable_over_ftp(printer_manager.get_status(printer_id))
+        if not storage.reachable:
+            _cover_404_cache.setdefault(printer_id, set()).add(cache_key)
+            raise HTTPException(
+                404,
+                f"The print file for '{subtask_name}' is not on storage Bambuddy can read over FTPS "
+                f"({storage.reason}), so it has no cover to extract.",
+            )
+
         logger.info(
             f"Trying to download cover for '{subtask_name}' from {printer.ip_address} (trying {len(remote_paths)} paths)"
         )
@@ -1227,6 +1240,16 @@ async def _produce_cover_image(
         last_error = None
 
         for attempt in range(max_retries + 1):
+            if ftps_handshake_blocked(printer.ip_address):
+                # Nothing to retry: the printer is not completing a TLS
+                # handshake on port 990, so no path and no attempt reaches it
+                # (#2780). Report the real cause instead of the 404 below,
+                # which would read as "this print has no cover".
+                raise HTTPException(
+                    503,
+                    f"Printer {printer.ip_address} is not answering its file service over TLS. "
+                    "Bambuddy will try again shortly.",
+                )
             try:
                 downloaded = await download_file_try_paths_async(
                     printer.ip_address,
@@ -2029,6 +2052,15 @@ async def stop_drying(
     success = printer_manager.send_drying_command(printer_id, ams_id, temp=0, duration=0, mode=0)
     if not success:
         raise HTTPException(400, "Printer not connected")
+
+    # A cycle the user stopped by hand tells us nothing about whether drying can
+    # move the humidity reading, so it must not count towards the auto-drying
+    # suspension (#2770). Imported here rather than at module scope to keep the
+    # existing routes/scheduler import direction.
+    from backend.app.services.print_scheduler import scheduler as print_scheduler
+
+    print_scheduler.forget_auto_dry_cycle(printer_id, ams_id)
+
     return {"status": "drying_stopped", "ams_id": ams_id}
 
 
@@ -2141,17 +2173,31 @@ async def get_inventory_remain(
     the dispatcher uses (#1766). Works for both internal inventory and
     Spoolman; unbound slots are absent from the map (client falls back to the
     printer's MQTT `remain` for those).
+
+    `slot_materials` carries the same bindings with their material identity and
+    extruder side attached, which is what the modal's pre-flight filament check
+    needs to pool spools under AMS Filament Backup the way the dispatcher does.
+    It is deliberately server-computed: the identity rule lives in
+    `filament_deficit`, and a client-side reimplementation of it is exactly how
+    the modal came to block prints the dispatcher would have accepted. Unlike
+    `inventory_remain_g` it covers every binding, not just currently-loaded
+    slots — again matching what the dispatcher pools.
     """
+    from backend.app.services.filament_deficit import build_slot_materials
     from backend.app.services.print_scheduler import PrintScheduler
 
     state = printer_manager.get_status(printer_id)
     if not state:
-        return {"inventory_remain_g": {}}
+        return {"inventory_remain_g": {}, "slot_materials": []}
 
     scheduler = PrintScheduler()
     loaded = scheduler._build_loaded_filaments(state)
     overrides = await scheduler._build_inventory_remain_overrides(db, printer_id, loaded)
-    return {"inventory_remain_g": {str(k): v for k, v in overrides.items()}}
+    slot_materials = await build_slot_materials(db, printer_id)
+    return {
+        "inventory_remain_g": {str(k): v for k, v in overrides.items()},
+        "slot_materials": [s.to_dict() for s in slot_materials],
+    }
 
 
 # ============================================
@@ -3163,7 +3209,12 @@ async def set_bed_temperature(
 @router.post("/{printer_id}/temperature/chamber")
 async def set_chamber_temperature(
     printer_id: int,
-    target: int = Query(..., ge=0, le=60, description="Target chamber temperature in Celsius; 0 turns heating off"),
+    target: int = Query(
+        ...,
+        ge=0,
+        le=MAX_CHAMBER_TEMP_C,
+        description="Target chamber temperature in Celsius; 0 turns heating off",
+    ),
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):

+ 295 - 106
backend/app/api/routes/projects.py

@@ -4,12 +4,14 @@ import logging
 import os
 import uuid
 import zipfile
+from collections.abc import Sequence
+from dataclasses import dataclass, fields
 from datetime import datetime
 from pathlib import Path
 
 from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
 from fastapi.responses import FileResponse, StreamingResponse
-from sqlalchemy import case, func, select
+from sqlalchemy import case, func, select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
@@ -68,10 +70,42 @@ _FAILURE_STATUSES = ("failed", "aborted", "cancelled", "stopped")
 _LIVE_ARCHIVE = PrintArchive.deleted_at.is_(None)
 
 
-async def compute_project_stats(
-    db: AsyncSession, project_id: int, target_count: int | None = None, target_parts_count: int | None = None
-) -> ProjectStats:
-    """Compute statistics for a project.
+@dataclass
+class _ProjectTotals:
+    """Raw per-project aggregates, before targets turn them into percentages.
+
+    Kept addable so a master project's numbers are the plain sum of its own
+    and every descendant's (#1264) — no second set of SQL that could drift
+    from the single-project path.
+    """
+
+    total_runs: int = 0
+    total_items: int = 0
+    completed_items: int = 0
+    failed_runs: int = 0
+    total_time_seconds: float = 0.0
+    total_filament_grams: float = 0.0
+    filament_cost: float = 0.0
+    energy_kwh: float = 0.0
+    energy_cost: float = 0.0
+    queued_prints: int = 0
+    in_progress_prints: int = 0
+    bom_total_items: int = 0
+    bom_completed_items: int = 0
+    bom_cost: float = 0.0
+
+    def __add__(self, other: "_ProjectTotals") -> "_ProjectTotals":
+        return _ProjectTotals(
+            **{f.name: getattr(self, f.name) + getattr(other, f.name) for f in fields(_ProjectTotals)}
+        )
+
+
+async def _load_totals(db: AsyncSession, project_ids: Sequence[int]) -> dict[int, _ProjectTotals]:
+    """Aggregate prints, queue and BOM for several projects at once.
+
+    Grouped rather than one round trip per project because a master project
+    has to aggregate its whole subtree, and the sub-project list shows each
+    branch's own roll-up alongside it (#1264).
 
     Aggregates from ``print_log_entries`` joined to ``print_archives`` so
     every actual run contributes — pre-fix this counted ``print_archives``
@@ -83,31 +117,28 @@ async def compute_project_stats(
     Orphan log entries (``archive_id IS NULL`` after archive deletion via
     ``ON DELETE SET NULL``) are excluded by the inner join — they can't
     be attributed to a project.
+
+    Projects with nothing recorded are absent from every grouped result, so
+    the caller gets a zeroed ``_ProjectTotals`` for them rather than a KeyError.
     """
-    # Per-run aggregates from print_log_entries joined on archive_id so
-    # the WHERE filters by archives.project_id. Each run's duration,
-    # filament, cost, and energy come from the log row, not the source
-    # archive — so multi-plate 3MFs and reprints both count correctly.
-    log_stats_result = await db.execute(
+    totals: dict[int, _ProjectTotals] = {pid: _ProjectTotals() for pid in project_ids}
+    if not totals:
+        return totals
+
+    # Per-run aggregates. Each run's duration, filament, cost, and energy come
+    # from the log row, not the source archive — so multi-plate 3MFs and
+    # reprints both count correctly. The total/completed/failed splits are all
+    # per-run too: quantity is summed per run, while failures are counted as
+    # runs rather than parts.
+    log_rows = await db.execute(
         select(
+            PrintArchive.project_id.label("project_id"),
             func.count(PrintLogEntry.id).label("total_runs"),
             func.coalesce(func.sum(PrintLogEntry.duration_seconds), 0).label("total_time"),
             func.coalesce(func.sum(PrintLogEntry.filament_used_grams), 0).label("total_filament"),
             func.coalesce(func.sum(PrintLogEntry.cost), 0).label("total_filament_cost"),
             func.coalesce(func.sum(PrintLogEntry.energy_kwh), 0).label("total_energy"),
             func.coalesce(func.sum(PrintLogEntry.energy_cost), 0).label("total_energy_cost"),
-        )
-        .join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
-        .where(PrintArchive.project_id == project_id, _LIVE_ARCHIVE)
-    )
-    log_stats = log_stats_result.first()
-    total_archives = int(log_stats.total_runs or 0)
-
-    # Total items the project has produced or attempted: sum of quantity
-    # per run (each run contributes its archive's quantity). The total/
-    # completed/failed splits are all per-run, not per-file.
-    items_split_result = await db.execute(
-        select(
             func.coalesce(func.sum(PrintArchive.quantity), 0).label("total_items"),
             func.coalesce(
                 func.sum(case((PrintLogEntry.status == "completed", PrintArchive.quantity), else_=0)),
@@ -119,77 +150,237 @@ async def compute_project_stats(
             ).label("failed_runs"),
         )
         .join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
-        .where(PrintArchive.project_id == project_id, _LIVE_ARCHIVE)
+        .where(PrintArchive.project_id.in_(list(totals)), _LIVE_ARCHIVE)
+        .group_by(PrintArchive.project_id)
     )
-    items_split = items_split_result.first()
-    total_items = int(items_split.total_items or 0)
-    completed_items = int(items_split.completed_items or 0)
-    failed_prints = int(items_split.failed_runs or 0)
-
-    # Count queued items
-    queued_result = await db.execute(
-        select(func.count(PrintQueueItem.id)).where(
-            PrintQueueItem.project_id == project_id, PrintQueueItem.status == "pending"
+    for row in log_rows:
+        entry = totals[row.project_id]
+        entry.total_runs = int(row.total_runs or 0)
+        entry.total_time_seconds = float(row.total_time or 0)
+        entry.total_filament_grams = float(row.total_filament or 0)
+        entry.filament_cost = float(row.total_filament_cost or 0)
+        entry.energy_kwh = float(row.total_energy or 0)
+        entry.energy_cost = float(row.total_energy_cost or 0)
+        entry.total_items = int(row.total_items or 0)
+        entry.completed_items = int(row.completed_items or 0)
+        entry.failed_runs = int(row.failed_runs or 0)
+
+    queue_rows = await db.execute(
+        select(
+            PrintQueueItem.project_id.label("project_id"),
+            func.coalesce(func.sum(case((PrintQueueItem.status == "pending", 1), else_=0)), 0).label("queued"),
+            func.coalesce(func.sum(case((PrintQueueItem.status == "printing", 1), else_=0)), 0).label("in_progress"),
         )
+        .where(PrintQueueItem.project_id.in_(list(totals)))
+        .group_by(PrintQueueItem.project_id)
     )
-    queued_prints = queued_result.scalar() or 0
+    for row in queue_rows:
+        entry = totals[row.project_id]
+        entry.queued_prints = int(row.queued or 0)
+        entry.in_progress_prints = int(row.in_progress or 0)
 
-    # Count in-progress items
-    in_progress_result = await db.execute(
-        select(func.count(PrintQueueItem.id)).where(
-            PrintQueueItem.project_id == project_id, PrintQueueItem.status == "printing"
+    bom_rows = await db.execute(
+        select(
+            ProjectBOMItem.project_id.label("project_id"),
+            func.count(ProjectBOMItem.id).label("total"),
+            func.sum(case((ProjectBOMItem.quantity_acquired >= ProjectBOMItem.quantity_needed, 1), else_=0)).label(
+                "completed"
+            ),
+            func.coalesce(func.sum(ProjectBOMItem.unit_price * ProjectBOMItem.quantity_needed), 0).label("bom_cost"),
         )
+        .where(ProjectBOMItem.project_id.in_(list(totals)))
+        .group_by(ProjectBOMItem.project_id)
     )
-    in_progress_prints = in_progress_result.scalar() or 0
+    for row in bom_rows:
+        entry = totals[row.project_id]
+        entry.bom_total_items = int(row.total or 0)
+        entry.bom_completed_items = int(row.completed or 0)
+        entry.bom_cost = float(row.bom_cost or 0)
+
+    return totals
 
+
+def _stats_from_totals(
+    totals: _ProjectTotals, target_count: int | None = None, target_parts_count: int | None = None
+) -> ProjectStats:
+    """Turn raw aggregates into the response shape, applying the targets."""
     # Calculate progress for plates (target_count vs total_archives)
     progress_percent = None
     remaining_prints = None
     if target_count and target_count > 0:
-        progress_percent = round((total_archives / target_count) * 100, 1)
-        remaining_prints = max(0, target_count - total_archives)
+        progress_percent = round((totals.total_runs / target_count) * 100, 1)
+        remaining_prints = max(0, target_count - totals.total_runs)
 
     # Calculate progress for parts (target_parts_count vs completed_items)
     parts_progress_percent = None
     remaining_parts = None
     if target_parts_count and target_parts_count > 0:
-        parts_progress_percent = round((completed_items / target_parts_count) * 100, 1)
-        remaining_parts = max(0, target_parts_count - completed_items)
-
-    # BOM stats
-    bom_result = await db.execute(
-        select(
-            func.count(ProjectBOMItem.id).label("total"),
-            func.sum(case((ProjectBOMItem.quantity_acquired >= ProjectBOMItem.quantity_needed, 1), else_=0)).label(
-                "completed"
-            ),
-            func.coalesce(func.sum(ProjectBOMItem.unit_price * ProjectBOMItem.quantity_needed), 0).label("bom_cost"),
-        ).where(ProjectBOMItem.project_id == project_id)
-    )
-    bom_stats = bom_result.first()
+        parts_progress_percent = round((totals.completed_items / target_parts_count) * 100, 1)
+        remaining_parts = max(0, target_parts_count - totals.completed_items)
 
     return ProjectStats(
-        total_archives=total_archives,
-        total_items=int(total_items),
-        completed_prints=completed_items,  # Now reflects sum of quantities for completed prints
-        failed_prints=int(failed_prints),
-        queued_prints=queued_prints,
-        in_progress_prints=in_progress_prints,
-        total_print_time_hours=round((log_stats.total_time or 0) / 3600, 2),
-        total_filament_grams=round(log_stats.total_filament or 0, 2),
+        total_archives=totals.total_runs,
+        total_items=totals.total_items,
+        completed_prints=totals.completed_items,  # Sum of quantities for completed prints
+        failed_prints=totals.failed_runs,
+        queued_prints=totals.queued_prints,
+        in_progress_prints=totals.in_progress_prints,
+        total_print_time_hours=round(totals.total_time_seconds / 3600, 2),
+        total_filament_grams=round(totals.total_filament_grams, 2),
         progress_percent=progress_percent,
         parts_progress_percent=parts_progress_percent,
-        estimated_cost=round((log_stats.total_filament_cost or 0), 2),
-        total_energy_kwh=round((log_stats.total_energy or 0), 3),
-        total_energy_cost=round((log_stats.total_energy_cost or 0), 3),
+        estimated_cost=round(totals.filament_cost, 2),
+        total_energy_kwh=round(totals.energy_kwh, 3),
+        total_energy_cost=round(totals.energy_cost, 3),
         remaining_prints=remaining_prints,
         remaining_parts=remaining_parts,
-        bom_total_items=bom_stats.total or 0,
-        bom_completed_items=int(bom_stats.completed or 0),
-        bom_cost=round(float(bom_stats.bom_cost or 0), 2),
+        bom_total_items=totals.bom_total_items,
+        bom_completed_items=totals.bom_completed_items,
+        bom_cost=round(totals.bom_cost, 2),
     )
 
 
+async def compute_project_stats(
+    db: AsyncSession, project_id: int, target_count: int | None = None, target_parts_count: int | None = None
+) -> ProjectStats:
+    """Compute statistics for a single project, excluding any sub-projects.
+
+    Sub-project roll-ups go through ``compute_subtree_stats`` instead. This
+    stays own-prints-only on purpose: it is what every existing caller means
+    by "this project's numbers", and widening it would silently restate the
+    figures of anyone who had already nested projects over the API.
+    """
+    totals = (await _load_totals(db, [project_id]))[project_id]
+    return _stats_from_totals(totals, target_count, target_parts_count)
+
+
+def _descendants_of(children: dict[int, list[int]], root_id: int) -> list[int]:
+    """Every project nested under ``root_id``, at any depth, root excluded.
+
+    Walked in Python off one already-fetched parent map rather than a recursive
+    CTE, so SQLite and PostgreSQL stay on identical code paths.
+
+    ``seen`` is not belt-and-braces. ``update_project`` only ever rejected a
+    project as its own *direct* parent, so any database written before that
+    guard was widened can hold A -> B -> A, and an unguarded walk over one
+    would never terminate.
+    """
+    found: list[int] = []
+    seen = {root_id}
+    stack = [root_id]
+    while stack:
+        for child in children.get(stack.pop(), ()):
+            if child in seen:
+                continue
+            seen.add(child)
+            found.append(child)
+            stack.append(child)
+    return found
+
+
+async def _project_descendants(db: AsyncSession, root_id: int) -> list[int]:
+    """``_descendants_of`` for callers that only need the ids, not the totals."""
+    rows = (await db.execute(select(Project.id, Project.parent_id).where(Project.parent_id.is_not(None)))).all()
+    children: dict[int, list[int]] = {}
+    for pid, parent_id in rows:
+        children.setdefault(parent_id, []).append(pid)
+    return _descendants_of(children, root_id)
+
+
+@dataclass
+class _SubtreeReport:
+    """What the detail endpoint needs to describe a project and its tree."""
+
+    descendant_count: int
+    # None when the project has no sub-projects: the roll-up would be identical
+    # to the project's own stats, and the UI uses its absence to stay quiet
+    # rather than showing a second, equal set of numbers.
+    rollup: ProjectStats | None
+    child_previews: list[ProjectChildPreview]
+
+
+async def compute_subtree_stats(db: AsyncSession, root_id: int) -> _SubtreeReport:
+    """Roll a project's own numbers up with every sub-project beneath it (#1264).
+
+    Four queries regardless of tree size or depth: one for the parent map, then
+    the three grouped aggregates in ``_load_totals`` covering the whole subtree
+    at once. Each direct child's preview carries *its* branch's roll-up, so the
+    listed rows add up to the master's total minus the master's own prints.
+    """
+    rows = (
+        await db.execute(
+            select(
+                Project.id,
+                Project.parent_id,
+                Project.name,
+                Project.color,
+                Project.status,
+                Project.target_count,
+                Project.target_parts_count,
+            )
+        )
+    ).all()
+    by_id = {row.id: row for row in rows}
+    children: dict[int, list[int]] = {}
+    for row in rows:
+        if row.parent_id is not None:
+            children.setdefault(row.parent_id, []).append(row.id)
+
+    descendants = _descendants_of(children, root_id)
+    if not descendants:
+        return _SubtreeReport(descendant_count=0, rollup=None, child_previews=[])
+
+    totals = await _load_totals(db, [root_id, *descendants])
+
+    def branch(node_id: int) -> tuple[_ProjectTotals, list[int]]:
+        """Totals for ``node_id`` plus everything under it, and that id list."""
+        ids = [node_id, *_descendants_of(children, node_id)]
+        summed = _ProjectTotals()
+        for pid in ids:
+            summed = summed + totals[pid]
+        return summed, ids
+
+    def summed_target(ids: Sequence[int], attr: str) -> int | None:
+        """Targets add up across the tree; all-unset stays unset, not zero."""
+        total = sum(getattr(by_id[pid], attr) or 0 for pid in ids)
+        return total or None
+
+    subtree_ids = [root_id, *descendants]
+    root_totals, _ = branch(root_id)
+    rollup = _stats_from_totals(
+        root_totals,
+        summed_target(subtree_ids, "target_count"),
+        summed_target(subtree_ids, "target_parts_count"),
+    )
+
+    previews: list[ProjectChildPreview] = []
+    for child_id in sorted(children.get(root_id, ()), key=lambda cid: by_id[cid].name):
+        child = by_id[child_id]
+        child_totals, child_ids = branch(child_id)
+        # Progress here is runs-against-plate-target, matching what the child's
+        # own page reports. It used to be completed *quantities* against the
+        # same target, so a row's percentage disagreed with the page it linked
+        # to.
+        child_stats = _stats_from_totals(child_totals, summed_target(child_ids, "target_count"))
+        previews.append(
+            ProjectChildPreview(
+                id=child.id,
+                name=child.name,
+                color=child.color,
+                status=child.status,
+                progress_percent=child_stats.progress_percent,
+                descendant_count=len(child_ids) - 1,
+                total_archives=child_stats.total_archives,
+                completed_prints=child_stats.completed_prints,
+                total_print_time_hours=child_stats.total_print_time_hours,
+                total_filament_grams=child_stats.total_filament_grams,
+                total_cost=round(child_stats.estimated_cost + child_stats.total_energy_cost + child_stats.bom_cost, 2),
+            )
+        )
+
+    return _SubtreeReport(descendant_count=len(descendants), rollup=rollup, child_previews=previews)
+
+
 @router.get("", response_model=list[ProjectListResponse])
 @router.get("/", response_model=list[ProjectListResponse])
 async def list_projects(
@@ -206,6 +397,20 @@ async def list_projects(
     result = await db.execute(query)
     projects = result.scalars().all()
 
+    # Direct sub-project counts for every project in one pass (#1264). Counted
+    # across all projects rather than the filtered page: a sub-project hidden
+    # by the status filter is still a sub-project, and a parent that claimed
+    # none would invite deleting it as if nothing hung off it.
+    child_counts = dict(
+        (
+            await db.execute(
+                select(Project.parent_id, func.count(Project.id))
+                .where(Project.parent_id.is_not(None))
+                .group_by(Project.parent_id)
+            )
+        ).all()
+    )
+
     # Compute quick stats for each project. Same per-run aggregation as
     # ``compute_project_stats`` — counts and quantities come from
     # ``print_log_entries`` joined to ``print_archives`` so reprints and
@@ -290,6 +495,8 @@ async def list_projects(
                 failed_count=failed_count,
                 queue_count=queue_count,
                 progress_percent=progress_percent,
+                parent_id=project.parent_id,
+                child_count=child_counts.get(project.id, 0),
                 archives=archive_previews,
                 url=project.url,
                 cover_image_filename=project.cover_image_filename,
@@ -501,38 +708,6 @@ async def create_project_from_template(
 # ============ Dynamic {project_id} Routes ============
 
 
-async def get_child_previews(db: AsyncSession, parent_id: int) -> list[ProjectChildPreview]:
-    """Get preview info for child projects."""
-    result = await db.execute(select(Project).where(Project.parent_id == parent_id).order_by(Project.name))
-    children = result.scalars().all()
-
-    previews = []
-    for child in children:
-        # Get completed count for progress (sum of quantities)
-        completed_result = await db.execute(
-            select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
-                PrintArchive.project_id == child.id,
-                PrintArchive.status == "completed",
-                _LIVE_ARCHIVE,
-            )
-        )
-        completed_count = completed_result.scalar() or 0
-        progress = None
-        if child.target_count and child.target_count > 0:
-            progress = round((int(completed_count) / child.target_count) * 100, 1)
-
-        previews.append(
-            ProjectChildPreview(
-                id=child.id,
-                name=child.name,
-                color=child.color,
-                status=child.status,
-                progress_percent=progress,
-            )
-        )
-    return previews
-
-
 @router.get("/{project_id}", response_model=ProjectResponse)
 async def get_project(
     project_id: int,
@@ -552,8 +727,7 @@ async def get_project(
         parent_result = await db.execute(select(Project.name).where(Project.id == project.parent_id))
         parent_name = parent_result.scalar()
 
-    # Get children
-    children = await get_child_previews(db, project.id)
+    subtree = await compute_subtree_stats(db, project.id)
 
     stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)
 
@@ -578,10 +752,12 @@ async def get_project(
         template_source_id=project.template_source_id,
         parent_id=project.parent_id,
         parent_name=parent_name,
-        children=children,
+        children=subtree.child_previews,
+        descendant_count=subtree.descendant_count,
         created_at=project.created_at,
         updated_at=project.updated_at,
         stats=stats,
+        rollup_stats=subtree.rollup,
     )
 
 
@@ -644,6 +820,12 @@ async def update_project(
             parent_result = await db.execute(select(Project).where(Project.id == data.parent_id))
             if not parent_result.scalar_one_or_none():
                 raise HTTPException(status_code=400, detail="Parent project not found")
+            # Refusing only the project itself left A -> B -> A reachable in two
+            # calls, and a cycle has no root to roll figures up to — the walk in
+            # ``_descendants_of`` would revisit forever without its seen-set
+            # (#1264).
+            if data.parent_id in await _project_descendants(db, project_id):
+                raise HTTPException(status_code=400, detail="Project cannot be moved under one of its own sub-projects")
             project.parent_id = data.parent_id
         else:
             project.parent_id = None
@@ -657,8 +839,7 @@ async def update_project(
         parent_result = await db.execute(select(Project.name).where(Project.id == project.parent_id))
         parent_name = parent_result.scalar()
 
-    # Get children
-    children = await get_child_previews(db, project.id)
+    subtree = await compute_subtree_stats(db, project.id)
 
     stats = await compute_project_stats(db, project.id, project.target_count, project.target_parts_count)
 
@@ -683,10 +864,12 @@ async def update_project(
         template_source_id=project.template_source_id,
         parent_id=project.parent_id,
         parent_name=parent_name,
-        children=children,
+        children=subtree.child_previews,
+        descendant_count=subtree.descendant_count,
         created_at=project.created_at,
         updated_at=project.updated_at,
         stats=stats,
+        rollup_stats=subtree.rollup,
     )
 
 
@@ -703,6 +886,12 @@ async def delete_project(
     if not project:
         raise HTTPException(status_code=404, detail="Project not found")
 
+    # Sub-projects move up to the deleted project's own parent rather than
+    # being cut loose at the top level, so deleting a middle layer collapses
+    # the tree by one instead of scattering a branch (#1264). Left to the ORM
+    # this would null their parent_id instead, which loses the grandparent.
+    await db.execute(update(Project).where(Project.parent_id == project_id).values(parent_id=project.parent_id))
+
     await db.delete(project)
 
     return {"message": "Project deleted"}

+ 61 - 18
backend/app/api/routes/settings.py

@@ -194,10 +194,13 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
             "default_vibration_cali",
             "default_layer_inspect",
             "default_timelapse",
+            "billing_enabled",
+            "printer_kill_switch_enabled",
             "ldap_enabled",
             "ldap_auto_provision",
             "local_login_enabled",
             "preheat_enabled",
+            "queue_keep_bed_warm",
         ]:
             settings_dict[setting.key] = setting.value.lower() == "true"
         elif setting.key in [
@@ -221,10 +224,13 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
             "stagger_group_size",
             "stagger_interval_minutes",
             "forecast_global_lead_time_days",
+            "finance_budget_reset_day",
             "session_max_hours",
             "pipeline_max_copies",
             "preheat_max_wait_seconds",
             "preheat_soak_seconds",
+            "queue_keep_warm_bed_temp",
+            "queue_keep_warm_max_minutes",
             "queue_max_concurrent_uploads",
         ]:
             settings_dict[setting.key] = int(setting.value)
@@ -809,15 +815,8 @@ async def _import_sqlite_to_postgres(sqlite_path: Path, postgres_url: str):
         sorted_tables = [t.name for t in metadata.sorted_tables if t.name in tables_to_import]
 
         # Phase 1: Drop all tables and recreate WITHOUT foreign keys.
-        # This avoids all FK ordering/orphan issues during import.
-        saved_fks = {}
-        for table in metadata.sorted_tables:
-            fks = list(table.foreign_key_constraints)
-            if fks:
-                saved_fks[table.name] = fks
-                for fk in fks:
-                    table.constraints.discard(fk)
-
+        # This avoids all FK ordering/orphan issues during import; the
+        # constraints go back on at the end, once every row has landed.
         async with pg_engine.begin() as conn:
             # Cap how long DROP TABLE will wait for AccessExclusiveLock so
             # any residual concurrent writer (per-printer MQTT clients
@@ -850,11 +849,38 @@ async def _import_sqlite_to_postgres(sqlite_path: Path, postgres_url: str):
             )
             await conn.run_sync(metadata.create_all)
 
-        # Restore FK definitions in metadata (needed for re-adding later)
-        for table_name, fks in saved_fks.items():
-            table_obj = metadata.tables[table_name]
-            for fk in fks:
-                table_obj.constraints.add(fk)
+            # Now strip the foreign keys, at the database level.
+            #
+            # This used to be done by discarding each ForeignKeyConstraint
+            # from `table.constraints` before `create_all`. That only
+            # suppresses the inline REFERENCES clause inside CREATE TABLE:
+            # `Table.foreign_key_constraints` is derived from the *columns'*
+            # ForeignKey objects, which the discard never touched. When
+            # `create_all` meets a dependency cycle it can't sort -- and
+            # library_files / library_folders / print_archives are exactly
+            # such a cycle -- it falls back to emitting those tables' keys
+            # as separate ALTER TABLE ... ADD FOREIGN KEY statements read
+            # straight from that property. Twelve constraints survived,
+            # including library_files.folder_id, and because the same cycle
+            # also drops the ordering edge from `sorted_tables` the child
+            # table was imported before its parent and the restore died on
+            # a ForeignKeyViolationError.
+            #
+            # Dropping them from pg_constraint instead is indifferent to how
+            # create_all chose to emit them, so a future model cycle cannot
+            # reintroduce this. It also keeps the app's global Base.metadata
+            # untouched: the old code only put the constraints back *after*
+            # the transaction, so a failure in here left the running process
+            # with an FK-less metadata until restart.
+            await conn.execute(
+                text(
+                    "DO $$ DECLARE r RECORD; BEGIN "
+                    "FOR r IN (SELECT conrelid::regclass AS tbl, conname FROM pg_constraint "
+                    "WHERE contype = 'f' AND connamespace = 'public'::regnamespace) LOOP "
+                    "EXECUTE 'ALTER TABLE ' || r.tbl || ' DROP CONSTRAINT ' || quote_ident(r.conname); "
+                    "END LOOP; END $$;"
+                )
+            )
 
         # Phase 2: Import data (no FKs to worry about)
         async with pg_engine.begin() as conn:
@@ -952,7 +978,7 @@ async def _import_sqlite_to_postgres(sqlite_path: Path, postgres_url: str):
         src.close()
         logger.info("Cross-database import complete: %d tables imported", len(tables_to_import))
 
-        # Recreate FK constraints from ORM metadata (not from saved definitions).
+        # Recreate FK constraints from ORM metadata, which Phase 1 left intact.
         # Use individual transactions so orphaned SQLite data doesn't block valid FKs.
         from sqlalchemy.schema import AddConstraint
 
@@ -962,11 +988,28 @@ async def _import_sqlite_to_postgres(sqlite_path: Path, postgres_url: str):
                 try:
                     async with pg_engine.begin() as fk_conn:
                         await fk_conn.execute(AddConstraint(fk))
-                except Exception:
-                    failed_fks.append(f"{table.name}.{fk.name}")
+                except Exception as e:
+                    # Name the constraint by what it links, not by `fk.name`:
+                    # these are unnamed in the ORM, so that field is None and
+                    # the warning used to read "print_archives.None" for every
+                    # one of the five keys on that table -- unusable for
+                    # working out which rows to go and look at.
+                    cols = ", ".join(c.name for c in fk.columns)
+                    target = fk.elements[0].target_fullname if fk.elements else "unknown"
+                    failed_fks.append(f"{table.name}({cols}) -> {target}")
+                    # Postgres puts the offending key in a DETAIL line; it
+                    # names the exact orphan value, which is the one thing
+                    # that turns this into an actionable report.
+                    detail = next(
+                        (ln.strip() for ln in str(e).splitlines() if ln.startswith("DETAIL:")),
+                        str(e).splitlines()[0] if str(e) else e.__class__.__name__,
+                    )
+                    logger.info("FK %s(%s) -> %s not restored: %s", table.name, cols, target, detail)
         if failed_fks:
             logger.warning(
-                "Could not restore %d FK constraints (orphaned data in SQLite): %s",
+                "Could not restore %d FK constraints (orphaned data in the backup): %s. "
+                "The data is restored and usable; those columns are simply no longer "
+                "enforced. See the INFO lines above for the offending key in each case.",
                 len(failed_fks),
                 ", ".join(failed_fks),
             )

+ 70 - 5
backend/app/api/routes/slicer_presets.py

@@ -32,6 +32,7 @@ from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.models.local_preset import LocalPreset
 from backend.app.models.user import User
+from backend.app.schemas.slicer import PresetRef
 from backend.app.schemas.slicer_presets import (
     UnifiedPreset,
     UnifiedPresetsBySlot,
@@ -46,9 +47,11 @@ from backend.app.services.orca_cloud import (
     OrcaCloudAuthError,
     OrcaCloudError,
 )
+from backend.app.services.preset_resolver import resolve_preset_ref
 from backend.app.services.slicer_api import (
     SlicerApiError,
     SlicerApiService,
+    SlicerApiUnavailableError,
 )
 from backend.app.utils.printer_models import PRINTER_MODEL_MAP
 
@@ -299,7 +302,7 @@ async def _fetch_local_presets(db: AsyncSession) -> dict[str, list[UnifiedPreset
             # Precise compatibility link — the slicer's own compatible_printers
             # list, captured at import time. Lets the SliceModal filter the
             # process / filament dropdowns by the selected printer without
-            # falling back to the uploaded-bundle index.
+            # falling back to the @BBL name matcher.
             preset.compatible_printers = _parse_compatible_printers(p.compatible_printers)
         slots[slot].append(preset)
     return slots
@@ -327,7 +330,7 @@ def _content_compatible_printers(content: dict) -> list[str] | None:
 def _parse_compatible_printers(raw: str | None) -> list[str] | None:
     """``LocalPreset.compatible_printers`` stores a JSON array of printer-preset
     names. Return the parsed list, or ``None`` on missing / malformed data so
-    the SliceModal falls back to the uploaded-bundle index for that preset."""
+    the SliceModal falls back to the name-based matcher for that preset."""
     if not raw:
         return None
     try:
@@ -530,15 +533,77 @@ def list_printer_models() -> dict[str, str]:
     "Bambu Lab <model>" form that appears in 3MF metadata and in slicer
     printer-preset names, values are the normalized short codes used in
     BambuStudio's `@BBL <code>` cloud-preset filenames. The frontend uses this
-    mapping to classify cloud / standard presets against the selected printer
-    when no slicer bundle has been uploaded that covers the preset (#1325
-    follow-up) - avoiding a second, manually-maintained model table on the
+    mapping to classify cloud / standard presets against the selected printer,
+    which carry no ``compatible_printers`` of their own (#1325 follow-up) -
+    avoiding a second, manually-maintained model table on the
     frontend. No auth gate: this is a static reference dictionary, not
     user data.
     """
     return dict(PRINTER_MODEL_MAP)
 
 
+@router.get("/preset-values")
+async def get_preset_values(
+    source: str = Query(..., description="Preset tier: 'local', 'cloud', 'orca_cloud' or 'standard'."),
+    id: str = Query(..., description="Preset id within that tier."),
+    slot: str = Query("process", description="Preset slot. Only 'process' is supported today."),
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
+) -> dict:
+    """Effective values of a preset, with its ``inherits:`` chain flattened.
+
+    Drives the slice modal's process-settings panel: without this the panel can
+    only show the option schema's compiled-in defaults, so a preset that sets a
+    0.42mm line width appears as the C++ default of 0.
+
+    The flattening is done by the *sidecar*, deliberately. A "Standard" pick is
+    only a ``{inherits: "<name>"}`` stub on our side, and even local/cloud
+    presets are deltas — the values live in the profile tree bundled inside the
+    running sidecar image. Bambuddy's own ``orca_profiles`` resolver walks
+    OrcaSlicer's published tree instead, which can disagree with what actually
+    slices; showing numbers from it would be confidently wrong.
+
+    Returns ``{"resolved": false, "values": {}, "reason": "..."}`` rather than
+    an error whenever the values can't be obtained. ``reason`` is what makes
+    the fallback actionable: a Bambuddy install pulls its sidecar as
+    ``SIDECAR_TAG:-latest`` regardless of its own release channel, so the
+    overwhelmingly common cause is a sidecar older than the endpoint — which
+    the user fixes by pulling a newer image, if we tell them that instead of
+    "could not read the values".
+    """
+    if slot != "process":
+        raise HTTPException(status_code=400, detail="Only the 'process' slot is supported")
+
+    ref = PresetRef(source=source, id=id)
+
+    def unresolved(reason: str) -> dict:
+        return {"resolved": False, "values": {}, "reason": reason}
+
+    try:
+        profile_json = await resolve_preset_ref(db, current_user, ref, slot)
+    except HTTPException:
+        # A preset the caller can't resolve is not a reason to break the panel;
+        # the slice itself will report it properly if they go ahead.
+        logger.info("Could not resolve %s preset %s for value lookup", slot, id)
+        return unresolved("preset_unresolved")
+
+    api_url = await _resolve_slicer_api_url(db)
+    if not api_url:
+        return unresolved("not_configured")
+
+    service = SlicerApiService(api_url)
+    try:
+        resolved = await service.resolve_profile(profile_json, "process")
+    except SlicerApiUnavailableError:
+        return unresolved("sidecar_unavailable")
+    finally:
+        await service.close()
+
+    if resolved.values is None:
+        return unresolved(resolved.reason)
+    return {"resolved": True, "values": resolved.values, "reason": "ok"}
+
+
 @router.get("/presets", response_model=UnifiedPresetsResponse)
 async def list_unified_presets(
     db: AsyncSession = Depends(get_db),

+ 103 - 19
backend/app/api/routes/smart_plugs.py

@@ -138,6 +138,90 @@ async def create_smart_plug(
     return plug
 
 
+def _is_script_plug(plug: SmartPlug) -> bool:
+    """Whether the plug is a Home Assistant script rather than a switchable device."""
+    return bool(plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script."))
+
+
+def _can_be_switched(plug: SmartPlug) -> bool:
+    """Whether ``control_smart_plug`` can actually turn this plug on and off.
+
+    Two kinds cannot, and the card's on/off button is useless on both:
+
+    - A Home Assistant script. It can be run, not switched.
+    - An MQTT plug. Bambuddy subscribes to it and never publishes, so the
+      control endpoint rejects it outright as monitor-only -- and an MQTT plug
+      is exactly the kind that reports watts, so without this it would win the
+      power tiebreak below and take the row off a plug that can be switched.
+    """
+    return not _is_script_plug(plug) and plug.plug_type != "mqtt"
+
+
+def _reports_power(plug: SmartPlug) -> bool:
+    """Whether the plug is configured with somewhere to read watts from (#2830).
+
+    Read from the configuration rather than measured: this runs on every printer
+    card render, and probing each plug would mean an HTTP round trip per plug.
+    So it is approximate in both directions -- an HA plug with no dedicated power
+    sensor may still report watts from the switch entity's own
+    ``current_power_w`` attribute, and a Tasmota device without energy metering
+    is counted here as if it had it. Only a live read could tell, and this is
+    used solely to break a tie between plugs that are otherwise equally
+    eligible, so neither miss can decide anything on its own.
+    """
+    if plug.plug_type == "homeassistant":
+        return bool(plug.ha_power_entity)
+    if plug.plug_type == "mqtt":
+        return bool(plug.mqtt_power_topic or plug.mqtt_topic)
+    if plug.plug_type == "rest":
+        return bool(plug.rest_power_path)
+    return True  # Tasmota, whose firmware reports power when the hardware has it
+
+
+def _main_plug_rank(plug: SmartPlug) -> tuple:
+    """Sort key for choosing the printer's main power plug, best first (#2830).
+
+    A printer's plugs are not interchangeable. The card's Power row carries the
+    power on/off and auto-off-after-print controls, so it has to land on the plug
+    that actually feeds the printer -- pointing those at an exhaust fan is the
+    same harm #2629 fixed for the scheduler's power-on. Ordered:
+
+    1. It can be switched at all -- see ``_can_be_switched``. The row's buttons
+       are the point of it.
+    2. ``controls_printer_power`` -- the flag that says this plug feeds the
+       printer, as opposed to an accessory that merely follows the print cycle.
+    3. ``enabled`` -- a disabled plug ignores automation, so its auto-off toggle
+       would sit there doing nothing.
+    4. ``show_on_printer_card`` -- ranked, not filtered: excluding hidden plugs
+       outright would strip the Power row, and with it the on/off button, from a
+       printer whose only plug has the flag off. It sorts below the power flag
+       because a display preference must not hand power control to an accessory.
+    5. Reports power, so the row shows watts rather than "--" where there is a
+       choice.
+    6. Lowest id, so the answer never depends on row order. The query had no
+       ORDER BY at all, which on Postgres means a plain UPDATE can move a row and
+       silently swap which plug the card calls the printer's power.
+    """
+    return (
+        not _can_be_switched(plug),
+        not plug.controls_printer_power,
+        not plug.enabled,
+        not plug.show_on_printer_card,
+        not _reports_power(plug),
+        plug.id,
+    )
+
+
+def _pick_main_plug(plugs: list[SmartPlug]) -> SmartPlug | None:
+    """The plug the printer card shows as its power, or None if there are none."""
+    return min(plugs, key=_main_plug_rank, default=None)
+
+
+async def _plugs_for_printer(db: AsyncSession, printer_id: int) -> list[SmartPlug]:
+    result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id).order_by(SmartPlug.id))
+    return list(result.scalars().all())
+
+
 @router.get("/by-printer/{printer_id}", response_model=SmartPlugResponse | None)
 async def get_smart_plug_by_printer(
     printer_id: int,
@@ -146,23 +230,11 @@ async def get_smart_plug_by_printer(
 ):
     """Get the main smart plug assigned to a printer.
 
-    When multiple plugs are assigned (e.g., a regular plug + script),
-    returns the main (non-script) plug for power control.
+    When several plugs are assigned -- a printer outlet, an enclosure fan, a
+    script -- returns the one that best fits the card's power controls. See
+    ``_main_plug_rank`` for the order and why.
     """
-    result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
-    plugs = result.scalars().all()
-
-    if not plugs:
-        return None
-
-    # If multiple plugs, prefer the non-script one (main power plug)
-    for plug in plugs:
-        is_script = plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.")
-        if not is_script:
-            return plug
-
-    # All are scripts, return the first one
-    return plugs[0]
+    return _pick_main_plug(await _plugs_for_printer(db, printer_id))
 
 
 @router.get("/by-printer/{printer_id}/scripts", response_model=list[SmartPlugResponse])
@@ -176,13 +248,25 @@ async def get_script_plugs_by_printer(
     Returns HA entities (switches, scripts, lights, etc.) for the printer that have
     show_on_printer_card enabled.
     Used to display action buttons alongside the main power plug.
+
+    A switchable main plug is left out: it is rendered directly above this row
+    with its own on/off button, so listing it here draws the same entity twice
+    (#2830). A script is not, because a printer whose only entities are scripts
+    falls back to showing one of them in the power row -- taking it out of this
+    row too would cost the one-click run it has always had there.
     """
-    result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
-    plugs = result.scalars().all()
+    plugs = await _plugs_for_printer(db, printer_id)
+    main_plug = _pick_main_plug(plugs)
+    duplicate_of_power_row = main_plug.id if main_plug and not _is_script_plug(main_plug) else None
 
     # Filter to HA entities with show_on_printer_card enabled
     ha_entities = [
-        plug for plug in plugs if plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.show_on_printer_card
+        plug
+        for plug in plugs
+        if plug.plug_type == "homeassistant"
+        and plug.ha_entity_id
+        and plug.show_on_printer_card
+        and plug.id != duplicate_of_power_row
     ]
     return ha_entities
 

+ 83 - 0
backend/app/api/routes/updates.py

@@ -110,6 +110,84 @@ def _is_docker_environment() -> bool:
     return False
 
 
+# Mount points the shipped compose file gives Bambuddy. Only these are
+# consulted when guessing the compose directory — an arbitrary bind mount
+# (a NAS share, an external library root) says nothing about where the
+# compose file lives.
+_COMPOSE_BIND_MOUNTPOINTS = ("/app/data", "/app/logs")
+
+# A named volume resolves to ``.../docker/volumes/<project>_bambuddy_data/_data``
+# in mountinfo. That names the compose *project* but reveals nothing about
+# the directory holding the compose file, so these entries are skipped.
+_DOCKER_NAMED_VOLUME_ROOT = re.compile(r"/docker/volumes/[^/]+/_data/?$")
+
+
+def _compose_dir_from_mountinfo() -> str | None:
+    """Guess the host directory holding the compose file, or None (#2664).
+
+    ``docker compose pull`` only works from the directory containing the
+    compose file, so the command the update box prints is unusable until the
+    user remembers where that is. Compose knows the answer — it stamps
+    ``com.docker.compose.project.working_dir`` onto every container it
+    creates — but reading your own labels requires the Docker socket, and
+    mounting that into Bambuddy would hand the container root-equivalent
+    access to the host in exchange for a convenience string. So we infer.
+
+    ``/proc/self/mountinfo`` exposes the *host* side of a bind mount in its
+    root field: a ``./data:/app/data`` line in the compose file surfaces as
+    ``/opt/bambuddy/data``, whose parent is the compose directory. The leaf
+    must match the mount point's own name before we take the parent —
+    ``/mnt/nas/prints:/app/data`` is a bind mount whose parent is emphatically
+    not a compose directory.
+
+    This is a guess and is treated as one — it only ever prefills the setting
+    the user can overwrite. The root field is relative to the *mounted device*
+    rather than to the host's ``/``, so a compose directory that sits under a
+    separate mount loses that mount's own prefix. Measured against real
+    containers: a compose file on the root filesystem (here a ZFS dataset
+    mounted at ``/``) came back exactly right, while one under ``/tmp`` — its
+    own tmpfs — inferred ``/claude-1001/...`` for ``/tmp/claude-1001/...``.
+    Nothing inside the container can tell the two apart, which is precisely
+    why the field is editable. The shipped compose file uses named volumes,
+    for which nothing is inferable at all.
+    """
+    try:
+        with open("/proc/self/mountinfo") as f:
+            lines = f.readlines()
+    except OSError:
+        return None
+
+    for line in lines:
+        parts = line.split()
+        # mountID parentID major:minor root mountPoint ...
+        if len(parts) < 5:
+            continue
+        root, mount_point = parts[3], parts[4]
+        if mount_point not in _COMPOSE_BIND_MOUNTPOINTS:
+            continue
+        if _DOCKER_NAMED_VOLUME_ROOT.search(root):
+            continue
+        parent, _, leaf = root.rstrip("/").rpartition("/")
+        if parent and leaf == mount_point.rsplit("/", 1)[-1]:
+            return parent
+    return None
+
+
+def _detect_compose_dir() -> str | None:
+    """Best-effort compose directory for the update instructions (#2664).
+
+    ``BAMBUDDY_COMPOSE_DIR`` wins when set — it is the only source that is
+    stated rather than inferred, and the shipped compose file carries a
+    commented ``${PWD}`` line for it.
+    """
+    env_dir = os.environ.get("BAMBUDDY_COMPOSE_DIR", "").strip()
+    if env_dir:
+        return env_dir
+    if not _is_docker_environment():
+        return None
+    return _compose_dir_from_mountinfo()
+
+
 def _is_ha_addon() -> bool:
     """Detect if running as a Home Assistant Supervisor addon.
 
@@ -527,6 +605,11 @@ async def check_for_updates(
                 "is_windows_installer": is_windows_installer,
                 "update_method": update_method,
                 "installer_download_url": installer_download_url,
+                # Prefill only — never the value the user saved. The settings
+                # response owns ``docker_compose_dir``; keeping the two apart
+                # means clearing the field falls back to the guess instead of
+                # resurrecting the cleared value from a stale update check.
+                "compose_dir_detected": _detect_compose_dir() if update_method == "docker" else None,
             }
 
     except httpx.HTTPError as e:

+ 39 - 1
backend/app/api/routes/users.py

@@ -13,6 +13,7 @@ from backend.app.core.auth import (
     ALGORITHM,
     SECRET_KEY,
     RequireAdminIfAuthEnabled,
+    RequireAnyPermissionIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     get_current_user_optional,
     get_password_hash,
@@ -34,13 +35,21 @@ from backend.app.models.settings import Settings
 from backend.app.models.user import User
 from backend.app.models.user_otp_code import UserOTPCode
 from backend.app.models.user_totp import UserTOTP
-from backend.app.schemas.auth import ChangePasswordRequest, GroupBrief, UserCreate, UserResponse, UserUpdate
+from backend.app.schemas.auth import (
+    ChangePasswordRequest,
+    GroupBrief,
+    UserCreate,
+    UserResponse,
+    UserSlim,
+    UserUpdate,
+)
 from backend.app.services.email_service import (
     create_welcome_email_from_template,
     generate_secure_password,
     get_smtp_settings,
     send_email,
 )
+from backend.app.services.finance_defaults import ensure_user_finance_defaults
 
 router = APIRouter(prefix="/users", tags=["users"])
 
@@ -164,6 +173,8 @@ async def create_user(
         new_user.groups = list(groups)
 
     db.add(new_user)
+    await db.flush()
+    await ensure_user_finance_defaults(db, new_user)
     await db.commit()
     await db.refresh(new_user)
 
@@ -187,6 +198,31 @@ async def create_user(
     return _user_to_response(new_user)
 
 
+@router.get("/slim", response_model=list[UserSlim])
+async def list_users_slim(
+    _: User | None = RequireAnyPermissionIfAuthEnabled(Permission.USERS_READ_SLIM, Permission.USERS_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    """List users as ``{id, username}`` only (#1894).
+
+    Exists so an API key -- or a group that should not see emails, roles and
+    permission sets -- can turn the ``created_by_id`` values it already gets
+    back from archives, stats and the queue into names.
+
+    ``USERS_READ`` is accepted alongside ``USERS_READ_SLIM`` because it is
+    strictly broader; groups that already hold it keep working without a
+    permission backfill. For API keys only the slim permission resolves (the
+    full one is unmapped = administrative), so a key reaches this and not the
+    listing above.
+
+    Declared before ``/{user_id}`` on purpose: FastAPI matches in declaration
+    order, and the reverse order would parse "slim" as the int path parameter
+    and answer 422.
+    """
+    result = await db.execute(select(User.id, User.username).order_by(User.username))
+    return [UserSlim(id=row.id, username=row.username) for row in result.all()]
+
+
 @router.get("/{user_id}", response_model=UserResponse)
 async def get_user(
     user_id: int,
@@ -307,6 +343,8 @@ async def update_user(
             )
         user.groups = list(groups)
 
+    await ensure_user_finance_defaults(db, user)
+
     await db.commit()
     result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
     user = result.scalar_one()

+ 13 - 7
backend/app/api/routes/webhook.py

@@ -5,7 +5,7 @@ from pydantic import BaseModel
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import check_permission, check_printer_access, get_api_key
+from backend.app.core.auth import check_printer_access, check_webhook_permission, get_api_key
 from backend.app.core.database import get_db
 from backend.app.models.api_key import APIKey
 from backend.app.models.archive import PrintArchive
@@ -68,7 +68,7 @@ async def webhook_add_to_queue(
 
     Requires 'can_queue' permission.
     """
-    check_permission(api_key, "queue")
+    await check_webhook_permission(db, api_key, "queue")
     check_printer_access(api_key, data.printer_id)
 
     # Verify archive exists
@@ -115,6 +115,10 @@ async def webhook_add_to_queue(
         scheduled_time=scheduled_time,
         require_previous_success=data.require_previous_success,
         auto_off_after=data.auto_off_after,
+        # Attribute to the key's owner so the item shows up under `queue:read_own`
+        # for the person whose key it is. Legacy keys predating per-user ownership
+        # have no `user_id`, and those rows stay ownerless.
+        created_by_id=api_key.user_id,
     )
     db.add(queue_item)
     await db.flush()
@@ -149,7 +153,7 @@ async def webhook_start_print(
 
     Requires 'can_control_printer' permission.
     """
-    check_permission(api_key, "control_printer")
+    await check_webhook_permission(db, api_key, "control_printer")
     check_printer_access(api_key, printer_id)
 
     # Get printer
@@ -187,12 +191,13 @@ async def webhook_start_print(
 async def webhook_stop_print(
     printer_id: int,
     api_key: APIKey = Depends(get_api_key),
+    db: AsyncSession = Depends(get_db),
 ):
     """Stop the current print on a printer.
 
     Requires 'can_control_printer' permission.
     """
-    check_permission(api_key, "control_printer")
+    await check_webhook_permission(db, api_key, "control_printer")
     check_printer_access(api_key, printer_id)
 
     status = printer_manager.get_status(printer_id)
@@ -218,12 +223,13 @@ async def webhook_stop_print(
 async def webhook_cancel_print(
     printer_id: int,
     api_key: APIKey = Depends(get_api_key),
+    db: AsyncSession = Depends(get_db),
 ):
     """Cancel the current print on a printer.
 
     Requires 'can_control_printer' permission.
     """
-    check_permission(api_key, "control_printer")
+    await check_webhook_permission(db, api_key, "control_printer")
     check_printer_access(api_key, printer_id)
 
     status = printer_manager.get_status(printer_id)
@@ -253,7 +259,7 @@ async def webhook_get_printer_status(
 
     Requires 'can_read_status' permission.
     """
-    check_permission(api_key, "read_status")
+    await check_webhook_permission(db, api_key, "read_status")
     check_printer_access(api_key, printer_id)
 
     # Get printer
@@ -289,7 +295,7 @@ async def webhook_get_queue_status(
 
     Requires 'can_read_status' permission.
     """
-    check_permission(api_key, "read_status")
+    await check_webhook_permission(db, api_key, "read_status")
 
     # Get printers
     if printer_id:

+ 211 - 27
backend/app/core/auth.py

@@ -49,8 +49,15 @@ logger = logging.getLogger(__name__)
 # The denylist is retained for documentation / drift-detection only — its
 # entries also satisfy "not in the allowlist", so they fail closed regardless.
 #
+# #1894 follow-on: the allowlist is a ceiling, not a grant. A key is also
+# narrowed to what its owner may do, so a user who can create keys cannot mint
+# themselves authority they do not have, and deactivating a user disables their
+# keys. Legacy ownerless keys (``user_id IS NULL``) have no owner to narrow
+# against and remain governed by the scope flags alone.
+#
 # Mapping rationale (see wiki/features/api-keys.md):
 #   can_read_status       → every ``*_READ`` + camera + stats + system + websocket
+#                           + the slim id/username user listing (NOT ``users:read``)
 #   can_queue             → queue write ops + archive reprint
 #   can_control_printer   → physical printer + smart-plug control
 #   can_manage_library    → library upload/own + MakerWorld import (separate
@@ -61,7 +68,13 @@ logger = logging.getLogger(__name__)
 #                           delete of admin resources, settings writes, user/
 #                           group/api-key/backup admin ops, discovery scan,
 #                           cloud auth, library ALL-ownership perms, purges
-_APIKEY_SCOPE_BY_PERMISSION: dict[Permission, str] = {
+#
+# A value may be a tuple of scope flags, in which case ALL of them must be True
+# on the key. That is for the rare permission whose route spans two trust
+# dimensions the operator toggles separately — see ``PIPELINES_RUN`` below.
+# Prefer a single flag; a tuple is a statement that neither flag alone
+# authorises what the route does.
+_APIKEY_SCOPE_BY_PERMISSION: dict[Permission, str | tuple[str, ...]] = {
     # can_read_status — read-only access to status, history, and configuration
     Permission.PRINTERS_READ: "can_read_status",
     # Legacy flat permissions retained for back-compat with custom API keys —
@@ -94,11 +107,23 @@ _APIKEY_SCOPE_BY_PERMISSION: dict[Permission, str] = {
     Permission.PRINTER_SENSOR_HISTORY_READ: "can_read_status",
     Permission.STATS_READ: "can_read_status",
     Permission.STATS_FILTER_BY_USER: "can_read_status",
+    # USERS_READ_SLIM grants no data an API key could not already reach (#1894):
+    # for API-keyed requests the permission deps return None as ``current_user``,
+    # so ``_validate_user_filter_permission`` in routes/archives.py short-circuits
+    # and ``?created_by_id=N`` is already honoured for every N. Without a way to
+    # discover the ids, that filter is only addressable by brute force. The slim
+    # listing makes it usable; the full USERS_READ listing (emails, roles, group
+    # membership, permission sets) stays unmapped = admin-only.
+    Permission.USERS_READ_SLIM: "can_read_status",
     Permission.SYSTEM_READ: "can_read_status",
     # SETTINGS_READ stays allowed via read-status so SpoolBuddy kiosks keep
     # working (they need the UI-language setting via API key).
     Permission.SETTINGS_READ: "can_read_status",
     Permission.MAKERWORLD_VIEW: "can_read_status",
+    # Pipeline definitions and run history are configuration + status: listing
+    # pipelines, reading a run, and the (write-free) POST check-eligibility
+    # pre-flight. Authoring stays admin-only under PIPELINES_WRITE.
+    Permission.PIPELINES_READ: "can_read_status",
     Permission.WEBSOCKET_CONNECT: "can_read_status",
     # can_queue — queue write ops + reprint (which enqueues an existing archive)
     Permission.QUEUE_CREATE: "can_queue",
@@ -179,6 +204,17 @@ _APIKEY_SCOPE_BY_PERMISSION: dict[Permission, str] = {
     Permission.PROJECTS_CREATE: "can_manage_projects",
     Permission.PROJECTS_UPDATE: "can_manage_projects",
     Permission.PROJECTS_DELETE: "can_manage_projects",
+    # can_queue AND can_manage_library — running a pipeline does two things a
+    # key is separately trusted with. It slices the source into a new library
+    # file (``slice_and_persist``, the same write the direct
+    # ``POST /library/files/{id}/slice`` route gates on LIBRARY_UPLOAD →
+    # can_manage_library), then creates one PrintQueueItem per copy for the
+    # scheduler to dispatch (can_queue). Mapping it to either flag alone would
+    # hand that flag the other one's authority, so both are required. Cancelling
+    # a run is the same permission — whoever may start one may stop it. PR A
+    # parked all three pipeline permissions on the denylist "until the run
+    # dispatch lands"; it landed in PR C (#1425) and this is that follow-up.
+    Permission.PIPELINES_RUN: ("can_queue", "can_manage_library"),
     # can_access_cloud — narrow opt-in scope, gated by the router-level
     # ``_cloud_api_key_gate`` and additionally enforced here so the route-
     # level ``cloud_caller(Permission.CLOUD_AUTH)`` dep also fails closed
@@ -216,6 +252,11 @@ _APIKEY_DENIED_PERMISSIONS: frozenset[Permission] = frozenset(
         Permission.API_KEYS_UPDATE,
         Permission.API_KEYS_DELETE,
         Permission.API_KEYS_READ,
+        # Finance / cost-center data has no dedicated API-key scope.
+        Permission.COST_CENTERS_READ_OWN,
+        Permission.COST_CENTERS_READ_ALL,
+        Permission.COST_CENTERS_MODIFY,
+        Permission.COST_CENTERS_CREATE,
         # GitHub backup admin + firmware OTA.
         Permission.GITHUB_BACKUP,
         Permission.GITHUB_RESTORE,
@@ -265,35 +306,129 @@ _APIKEY_DENIED_PERMISSIONS: frozenset[Permission] = frozenset(
         Permission.SMART_PLUGS_DELETE,
         # Network scanning — operator only (no API-key scope for this).
         Permission.DISCOVERY_SCAN,
-        # Slicer Pipelines (#1425) — admin authoring + the print-spending Run
-        # action. PR A only ships CRUD; PR B / PR C may move PIPELINES_RUN onto
-        # `can_queue` (it queues prints) once the run dispatch lands. PR A keeps
-        # all three denied so they fail closed for any API-key surface.
-        Permission.PIPELINES_READ,
+        # Slicer Pipelines (#1425) — authoring only. PIPELINES_READ and
+        # PIPELINES_RUN moved to the allowlist once PR C landed the run
+        # dispatch; PIPELINES_WRITE stays denied because it creates/edits/
+        # deletes the pipeline definition (slicer settings, target printer,
+        # fanout strategy) and, via `POST /pipeline-runs/clear`, drops run
+        # history. That is admin authoring, matching the other resource-CRUD
+        # entries here — a key that may run a pipeline cannot rewrite what it
+        # does.
         Permission.PIPELINES_WRITE,
-        Permission.PIPELINES_RUN,
     }
 )
 
 
-def _resolve_apikey_scope(perm_string: str) -> str | None:
-    """Return the scope-flag attribute name gating ``perm_string`` for API keys.
+def _required_apikey_scopes(perm_string: str) -> tuple[str, ...] | None:
+    """Return every scope flag a key must hold to exercise ``perm_string``.
 
-    None when the permission is unmapped (= admin-only / not API-key-usable).
+    None when the permission is unmapped (= admin-only / not API-key-usable),
+    which is distinct from an empty tuple — the latter would read as "no flags
+    needed" and must never be produced.
     """
     try:
         perm = Permission(perm_string)
     except ValueError:
         return None
-    return _APIKEY_SCOPE_BY_PERMISSION.get(perm)
+    scopes = _APIKEY_SCOPE_BY_PERMISSION.get(perm)
+    if scopes is None:
+        return None
+    return (scopes,) if isinstance(scopes, str) else tuple(scopes)
+
+
+def apikey_effective_permissions(api_key: APIKey, owner: User | None = None) -> list[str]:
+    """Return the permissions ``api_key`` can actually exercise, sorted.
 
+    This is the exact set ``_check_apikey_permissions`` will let through: every
+    mapped permission whose scope flag is True on the key, further narrowed to
+    what ``owner`` may do. Unmapped permissions are administrative and never
+    resolve for a key, so they are absent.
+
+    ``owner=None`` means a legacy ownerless key, where the scope flags are the
+    whole of the key's authority -- not "skip the owner check". Callers holding
+    an owned key must pass the owner, or ``/auth/me`` will over-report and drift
+    from the gate, which is the defect #1894 was about.
+    """
+
+    def _granted(perm: Permission) -> bool:
+        scopes = _required_apikey_scopes(perm.value)
+        # An unmapped permission cannot occur here (we iterate the mapping
+        # itself), but treat it as denied rather than as "no flags to satisfy",
+        # which ``all(())`` would otherwise report as granted.
+        if not scopes:
+            return False
+        return all(getattr(api_key, flag, False) for flag in scopes)
 
-def _check_apikey_permissions(api_key: APIKey, perm_strings: list[str], *, require_any: bool = False) -> None:
+    return sorted(
+        perm.value
+        for perm in _APIKEY_SCOPE_BY_PERMISSION
+        if _granted(perm) and (owner is None or owner.has_permission(perm.value))
+    )
+
+
+async def resolve_apikey_owner(db: AsyncSession, api_key: APIKey) -> User | None:
+    """Load the owner of ``api_key`` for an authorization decision.
+
+    Distinct from ``_user_from_api_key``, which answers "who is this, if
+    anyone" and returns None for both the legacy and the broken case. Here
+    those two must not be conflated:
+
+    - ``user_id IS NULL`` -- a key predating per-user ownership. There is no
+      owner to narrow against, so the scope flags stand alone. Returns None.
+    - ``user_id`` set but the row is missing or deactivated -- the key's
+      authority came from a user who no longer has any. Raises 403 rather than
+      returning None, because returning None here would fail open: deactivating
+      a user would leave their keys working with full scope authority.
+
+    Groups are eager-loaded because ``has_permission`` walks them, and a lazy
+    load inside the permission check would raise MissingGreenlet.
+    """
+    if api_key.user_id is None:
+        return None
+    result = await db.execute(select(User).where(User.id == api_key.user_id).options(selectinload(User.groups)))
+    owner = result.scalar_one_or_none()
+    if owner is None or not owner.is_active:
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN,
+            detail="API key owner is deactivated or no longer exists",
+        )
+    return owner
+
+
+async def authorize_api_key(
+    db: AsyncSession,
+    api_key: APIKey,
+    perm_strings: list[str],
+    *,
+    require_any: bool = False,
+) -> None:
+    """Resolve the key's owner and run the full permission gate. Raises 403."""
+    owner = await resolve_apikey_owner(db, api_key)
+    _check_apikey_permissions(api_key, perm_strings, owner=owner, require_any=require_any)
+
+
+def _check_apikey_permissions(
+    api_key: APIKey,
+    perm_strings: list[str],
+    *,
+    owner: User | None = None,
+    require_any: bool = False,
+) -> None:
     """Raise 403 unless ``api_key`` is allowed to use ``perm_strings``.
 
     Allowlist semantics: every requested permission MUST be present in
-    ``_APIKEY_SCOPE_BY_PERMISSION`` AND its scope flag must be True on
-    ``api_key``. Unmapped permissions = administrative = 403.
+    ``_APIKEY_SCOPE_BY_PERMISSION`` AND every scope flag it maps to must be
+    True on ``api_key`` (most map to one; a few require several). Unmapped
+    permissions = administrative = 403.
+
+    A key must not out-rank the user it belongs to, so when ``owner`` is given
+    the permission must additionally be one the owner holds. Scope flags are
+    chosen at creation time by whoever holds ``api_keys:create``; that is
+    admin-only in the default groups, but a custom group can grant it, and
+    without this check such a user could mint themselves a key with
+    ``can_control_printer`` and act through it beyond their own permissions.
+    ``owner=None`` is only correct for legacy ownerless keys -- see
+    ``resolve_apikey_owner``.
 
     By default ALL requested permissions must pass (mirrors
     ``require_permission`` / ``require_permission_if_auth_enabled``).
@@ -311,16 +446,25 @@ def _check_apikey_permissions(api_key: APIKey, perm_strings: list[str], *, requi
 
     last_failure: HTTPException | None = None
     for perm_str in perm_strings:
-        scope_attr = _resolve_apikey_scope(perm_str)
-        if scope_attr is None:
+        scopes = _required_apikey_scopes(perm_str)
+        missing = [flag for flag in scopes or () if not getattr(api_key, flag, False)]
+        if not scopes:
             failure = HTTPException(
                 status_code=status.HTTP_403_FORBIDDEN,
                 detail="API keys cannot be used for administrative operations",
             )
-        elif not getattr(api_key, scope_attr, False):
+        elif missing:
+            # Name every flag the key is short of, not just the first: a
+            # permission requiring two scopes would otherwise send the operator
+            # round the loop twice, ticking one box per 403.
             failure = HTTPException(
                 status_code=status.HTTP_403_FORBIDDEN,
-                detail=f"API key does not have '{scope_attr}' permission",
+                detail=f"API key does not have {' and '.join(repr(flag) for flag in missing)} permission",
+            )
+        elif owner is not None and not owner.has_permission(perm_str):
+            failure = HTTPException(
+                status_code=status.HTTP_403_FORBIDDEN,
+                detail=f"API key owner does not have '{perm_str}' permission",
             )
         else:
             failure = None
@@ -379,6 +523,12 @@ def require_energy_cost_update():
                         detail="Invalid API key",
                         headers={"WWW-Authenticate": "Bearer"},
                     )
+                # Fails closed if the owner has been deactivated. The scope
+                # flag itself is not narrowed against the owner's permissions
+                # the way the general gate is: this door exists precisely
+                # because no user permission maps to it (SETTINGS_UPDATE stays
+                # denied for keys even when the owner is an administrator).
+                await resolve_apikey_owner(db, api_key)
                 if not api_key.can_update_energy_cost:
                     raise HTTPException(
                         status_code=status.HTTP_403_FORBIDDEN,
@@ -1128,10 +1278,14 @@ async def require_auth_if_enabled(
         if not auth_enabled:
             return None
 
-        # Check for API key first (X-API-Key header)
+        # Check for API key first (X-API-Key header). The owner is resolved
+        # purely for its side effect: a key whose owner has been deactivated
+        # must be dead everywhere, not just on the permission-gated routes.
+        # There is no permission to check here -- this dep is auth-only.
         if x_api_key:
             api_key = await _validate_api_key(db, x_api_key)
             if api_key:
+                await resolve_apikey_owner(db, api_key)
                 return None  # API key valid, allow access
 
         # Check for Bearer token (could be JWT or API key)
@@ -1141,6 +1295,7 @@ async def require_auth_if_enabled(
             if token.startswith("bb_"):
                 api_key = await _validate_api_key(db, token)
                 if api_key:
+                    await resolve_apikey_owner(db, api_key)
                     return None  # API key valid, allow access
                 raise HTTPException(
                     status_code=status.HTTP_401_UNAUTHORIZED,
@@ -1419,6 +1574,35 @@ def check_permission(api_key: APIKey, permission: str) -> None:
         )
 
 
+# The coarse webhook permission names predate the Permission enum. Each maps to
+# the enum member that best represents it, so the owner can be held to the same
+# standard here as on the modern routes.
+_WEBHOOK_PERMISSION_EQUIVALENT: dict[str, Permission] = {
+    "queue": Permission.QUEUE_CREATE,
+    "control_printer": Permission.PRINTERS_CONTROL,
+    "read_status": Permission.PRINTERS_READ,
+}
+
+
+async def check_webhook_permission(db: AsyncSession, api_key: APIKey, permission: str) -> None:
+    """``check_permission`` plus the owner checks the modern routes apply.
+
+    ``/webhook/*`` reaches its scope flags through ``check_permission`` rather
+    than ``_check_apikey_permissions``, so it does not pick up the owner
+    narrowing automatically. Without this it would be the way around the gate:
+    the same key that is refused printer control on ``/printers/{id}/print/stop``
+    could stop the print through ``/webhook/printer/{id}/stop``.
+    """
+    check_permission(api_key, permission)
+    owner = await resolve_apikey_owner(db, api_key)
+    equivalent = _WEBHOOK_PERMISSION_EQUIVALENT.get(permission)
+    if owner is not None and equivalent is not None and not owner.has_permission(equivalent.value):
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN,
+            detail=f"API key owner does not have '{equivalent.value}' permission",
+        )
+
+
 def check_printer_access(api_key: APIKey, printer_id: int) -> None:
     """Check if API key has access to the specified printer.
 
@@ -1476,7 +1660,7 @@ def require_permission(*permissions: str | Permission):
             if x_api_key:
                 api_key = await _validate_api_key(db, x_api_key)
                 if api_key:
-                    _check_apikey_permissions(api_key, perm_strings)
+                    await authorize_api_key(db, api_key, perm_strings)
                     return None  # API key valid, allow access
 
             credentials_exception = HTTPException(
@@ -1493,7 +1677,7 @@ def require_permission(*permissions: str | Permission):
             if token.startswith("bb_"):
                 api_key = await _validate_api_key(db, token)
                 if api_key:
-                    _check_apikey_permissions(api_key, perm_strings)
+                    await authorize_api_key(db, api_key, perm_strings)
                     return None  # API key valid, allow access
                 raise HTTPException(
                     status_code=status.HTTP_401_UNAUTHORIZED,
@@ -1566,7 +1750,7 @@ def require_permission_if_auth_enabled(*permissions: str | Permission):
             if x_api_key:
                 api_key = await _validate_api_key(db, x_api_key)
                 if api_key:
-                    _check_apikey_permissions(api_key, perm_strings)
+                    await authorize_api_key(db, api_key, perm_strings)
                     return None  # API key valid, allow access
 
             # Check for Bearer token (could be JWT or API key)
@@ -1576,7 +1760,7 @@ def require_permission_if_auth_enabled(*permissions: str | Permission):
                 if token.startswith("bb_"):
                     api_key = await _validate_api_key(db, token)
                     if api_key:
-                        _check_apikey_permissions(api_key, perm_strings)
+                        await authorize_api_key(db, api_key, perm_strings)
                         return None  # API key valid, allow access
                     raise HTTPException(
                         status_code=status.HTTP_401_UNAUTHORIZED,
@@ -1669,7 +1853,7 @@ def require_any_permission_if_auth_enabled(*permissions: str | Permission):
                     # GHSA-r2qv-8222-hqg3: previously returned None unconditionally,
                     # letting any valid API key satisfy admin "any-of" route
                     # dependencies. require_any → at-least-one must pass the scope check.
-                    _check_apikey_permissions(api_key, perm_strings, require_any=True)
+                    await authorize_api_key(db, api_key, perm_strings, require_any=True)
                     return None
 
             if credentials is not None:
@@ -1677,7 +1861,7 @@ def require_any_permission_if_auth_enabled(*permissions: str | Permission):
                 if token.startswith("bb_"):
                     api_key = await _validate_api_key(db, token)
                     if api_key:
-                        _check_apikey_permissions(api_key, perm_strings, require_any=True)
+                        await authorize_api_key(db, api_key, perm_strings, require_any=True)
                         return None
                     raise HTTPException(
                         status_code=status.HTTP_401_UNAUTHORIZED,
@@ -1868,7 +2052,7 @@ def require_ownership_permission(
             if x_api_key:
                 api_key = await _validate_api_key(db, x_api_key)
                 if api_key:
-                    _check_apikey_permissions(api_key, [all_perm])
+                    await authorize_api_key(db, api_key, [all_perm])
                     return None, True
 
             # Check for Bearer token (could be JWT or API key)
@@ -1878,7 +2062,7 @@ def require_ownership_permission(
                 if token.startswith("bb_"):
                     api_key = await _validate_api_key(db, token)
                     if api_key:
-                        _check_apikey_permissions(api_key, [all_perm])
+                        await authorize_api_key(db, api_key, [all_perm])
                         return None, True
                     raise HTTPException(
                         status_code=status.HTTP_401_UNAUTHORIZED,

+ 1 - 1
backend/app/core/config.py

@@ -7,7 +7,7 @@ from pydantic import Field
 from pydantic_settings import BaseSettings
 
 # Application version - single source of truth
-APP_VERSION = "1.2.5.2"
+APP_VERSION = "1.2.5.3"
 GITHUB_REPO = "maziggy/bambuddy"
 BUG_REPORT_RELAY_URL = os.environ.get("BUG_REPORT_RELAY_URL", "https://bambuddy.cool/api/bug-report")
 

+ 656 - 8
backend/app/core/database.py

@@ -250,6 +250,7 @@ async def get_db() -> AsyncSession:
 async def init_db():
     # Import models to register them with SQLAlchemy
     from backend.app.models import (  # noqa: F401
+        active_print_session,
         active_print_spoolman,
         ams_history,
         ams_label,
@@ -261,6 +262,7 @@ async def init_db():
         external_link,
         filament,
         filament_sku_settings,
+        finance,
         github_backup,
         group,
         kprofile_note,
@@ -279,6 +281,7 @@ async def init_db():
         print_log,
         print_queue,
         printer,
+        printer_ha_sensor,
         printer_sensor_history,
         project,
         project_bom,
@@ -1023,6 +1026,219 @@ async def _migrate_widen_spoolman_slot_ams_id_range(conn) -> None:
         raise
 
 
+async def _migrate_create_finance_tables(conn) -> None:
+    """Create finance tables missing from databases that predate billing.
+
+    ``Base.metadata.create_all()`` covers fresh installs, but upgrade and
+    restore paths can run the handwritten migrations against an existing
+    PostgreSQL schema.  The finance column migrations below must therefore not
+    assume these tables already exist.
+
+    ``UserWallet`` is mapped to ``user_wallets``.
+    """
+    if is_sqlite():
+        statements = [
+            """
+            CREATE TABLE IF NOT EXISTS cost_centers (
+                id INTEGER PRIMARY KEY,
+                code VARCHAR(32) NOT NULL UNIQUE,
+                name VARCHAR(150) NOT NULL,
+                is_active BOOLEAN NOT NULL DEFAULT 1,
+                is_private BOOLEAN NOT NULL DEFAULT 0,
+                owner_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
+                total_budget NUMERIC(14,2),
+                monthly_budget NUMERIC(14,2),
+                created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+            )
+            """,
+            """
+            CREATE TABLE IF NOT EXISTS user_wallets (
+                id INTEGER PRIMARY KEY,
+                user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
+                balance NUMERIC(14,2) NOT NULL DEFAULT 0.0,
+                currency VARCHAR(3) NOT NULL DEFAULT 'EUR',
+                updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+            )
+            """,
+            """
+            CREATE TABLE IF NOT EXISTS cost_center_members (
+                id INTEGER PRIMARY KEY,
+                cost_center_id INTEGER NOT NULL REFERENCES cost_centers(id) ON DELETE CASCADE,
+                user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+                can_print BOOLEAN NOT NULL DEFAULT 1,
+                created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                CONSTRAINT uq_cost_center_members_cc_user UNIQUE (cost_center_id, user_id)
+            )
+            """,
+            """
+            CREATE TABLE IF NOT EXISTS wallet_transactions (
+                id INTEGER PRIMARY KEY,
+                user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+                cost_center_id INTEGER REFERENCES cost_centers(id) ON DELETE SET NULL,
+                transaction_type VARCHAR(40) NOT NULL,
+                amount NUMERIC(14,2) NOT NULL,
+                balance_after NUMERIC(14,2),
+                description TEXT,
+                created_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
+                print_run_id VARCHAR(100),
+                print_archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL,
+                print_queue_id INTEGER REFERENCES print_queue(id) ON DELETE SET NULL,
+                is_voided BOOLEAN NOT NULL DEFAULT 0,
+                created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                CONSTRAINT ck_wallet_transactions_transaction_type CHECK (
+                    transaction_type IN ('print_charge', 'deposit', 'withdraw', 'manual_adjustment')
+                )
+            )
+            """,
+            """
+            CREATE TABLE IF NOT EXISTS budget_reservations (
+                id INTEGER PRIMARY KEY,
+                cost_center_id INTEGER NOT NULL REFERENCES cost_centers(id) ON DELETE CASCADE,
+                amount NUMERIC(14,2) NOT NULL,
+                status VARCHAR(20) NOT NULL,
+                source_type VARCHAR(50) NOT NULL,
+                source_id INTEGER,
+                print_archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL,
+                created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                released_at DATETIME
+            )
+            """,
+        ]
+    else:
+        statements = [
+            """
+            CREATE TABLE IF NOT EXISTS cost_centers (
+                id SERIAL PRIMARY KEY,
+                code VARCHAR(32) NOT NULL UNIQUE,
+                name VARCHAR(150) NOT NULL,
+                is_active BOOLEAN NOT NULL DEFAULT TRUE,
+                is_private BOOLEAN NOT NULL DEFAULT FALSE,
+                owner_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
+                total_budget NUMERIC(14,2),
+                monthly_budget NUMERIC(14,2),
+                created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+            )
+            """,
+            """
+            CREATE TABLE IF NOT EXISTS user_wallets (
+                id SERIAL PRIMARY KEY,
+                user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
+                balance NUMERIC(14,2) NOT NULL DEFAULT 0.0,
+                currency VARCHAR(3) NOT NULL DEFAULT 'EUR',
+                updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+            )
+            """,
+            """
+            CREATE TABLE IF NOT EXISTS cost_center_members (
+                id SERIAL PRIMARY KEY,
+                cost_center_id INTEGER NOT NULL REFERENCES cost_centers(id) ON DELETE CASCADE,
+                user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+                can_print BOOLEAN NOT NULL DEFAULT TRUE,
+                created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                CONSTRAINT uq_cost_center_members_cc_user UNIQUE (cost_center_id, user_id)
+            )
+            """,
+            """
+            CREATE TABLE IF NOT EXISTS wallet_transactions (
+                id SERIAL PRIMARY KEY,
+                user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+                cost_center_id INTEGER REFERENCES cost_centers(id) ON DELETE SET NULL,
+                transaction_type VARCHAR(40) NOT NULL,
+                amount NUMERIC(14,2) NOT NULL,
+                balance_after NUMERIC(14,2),
+                description TEXT,
+                created_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
+                print_run_id VARCHAR(100),
+                print_archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL,
+                print_queue_id INTEGER REFERENCES print_queue(id) ON DELETE SET NULL,
+                is_voided BOOLEAN NOT NULL DEFAULT FALSE,
+                created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                CONSTRAINT ck_wallet_transactions_transaction_type CHECK (
+                    transaction_type IN ('print_charge', 'deposit', 'withdraw', 'manual_adjustment')
+                )
+            )
+            """,
+            """
+            CREATE TABLE IF NOT EXISTS budget_reservations (
+                id SERIAL PRIMARY KEY,
+                cost_center_id INTEGER NOT NULL REFERENCES cost_centers(id) ON DELETE CASCADE,
+                amount NUMERIC(14,2) NOT NULL,
+                status VARCHAR(20) NOT NULL,
+                source_type VARCHAR(50) NOT NULL,
+                source_id INTEGER,
+                print_archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL,
+                created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                released_at TIMESTAMP
+            )
+            """,
+        ]
+
+    for statement in statements:
+        await _safe_execute(conn, statement)
+
+
+async def _migrate_create_finance_indexes(conn) -> None:
+    """Create finance indexes after legacy tables have received new columns."""
+    # Older billing migrations created this as a non-unique index. Recreate it
+    # so upgraded databases enforce the same constraint as the ORM model.
+    await _safe_execute(conn, "DROP INDEX IF EXISTS ix_cost_centers_code")
+    indexes = [
+        "CREATE UNIQUE INDEX IF NOT EXISTS ix_cost_centers_code ON cost_centers (code)",
+        "CREATE INDEX IF NOT EXISTS ix_cost_centers_name ON cost_centers (name)",
+        "CREATE UNIQUE INDEX IF NOT EXISTS ix_user_wallets_user_id ON user_wallets (user_id)",
+        "CREATE INDEX IF NOT EXISTS ix_cost_center_members_cost_center_id ON cost_center_members (cost_center_id)",
+        "CREATE INDEX IF NOT EXISTS ix_cost_center_members_user_id ON cost_center_members (user_id)",
+        "CREATE INDEX IF NOT EXISTS ix_wallet_transactions_user_id ON wallet_transactions (user_id)",
+        "CREATE INDEX IF NOT EXISTS ix_wallet_transactions_cost_center_id ON wallet_transactions (cost_center_id)",
+        "CREATE INDEX IF NOT EXISTS ix_wallet_transactions_transaction_type ON wallet_transactions (transaction_type)",
+        "CREATE INDEX IF NOT EXISTS ix_wallet_transactions_created_by_user_id "
+        "ON wallet_transactions (created_by_user_id)",
+        "CREATE INDEX IF NOT EXISTS ix_wallet_transactions_print_run_id ON wallet_transactions (print_run_id)",
+        "CREATE INDEX IF NOT EXISTS ix_wallet_transactions_print_archive_id ON wallet_transactions (print_archive_id)",
+        "CREATE INDEX IF NOT EXISTS ix_wallet_transactions_print_queue_id ON wallet_transactions (print_queue_id)",
+        "CREATE INDEX IF NOT EXISTS ix_wallet_transactions_created_at ON wallet_transactions (created_at)",
+        "CREATE INDEX IF NOT EXISTS ix_budget_reservations_cost_center_id ON budget_reservations (cost_center_id)",
+        "CREATE INDEX IF NOT EXISTS ix_budget_reservations_status ON budget_reservations (status)",
+        "CREATE INDEX IF NOT EXISTS ix_budget_reservations_source_type ON budget_reservations (source_type)",
+        "CREATE INDEX IF NOT EXISTS ix_budget_reservations_source_id ON budget_reservations (source_id)",
+        "CREATE INDEX IF NOT EXISTS ix_budget_reservations_print_archive_id ON budget_reservations (print_archive_id)",
+    ]
+    for statement in indexes:
+        await _safe_execute(conn, statement)
+
+
+async def _migrate_finance_money_to_numeric(conn) -> None:
+    """Convert persisted finance money columns on PostgreSQL upgrades."""
+    if is_sqlite():
+        # SQLite uses dynamic type affinity. New tables declare NUMERIC, while
+        # existing values remain protected by cent-rounding at write/rebuild.
+        return
+
+    columns = {
+        "cost_centers": ("total_budget", "monthly_budget"),
+        "user_wallets": ("balance",),
+        "wallet_transactions": ("amount", "balance_after"),
+        "budget_reservations": ("amount",),
+    }
+    for table_name, column_names in columns.items():
+        for column_name in column_names:
+            await _safe_execute(
+                conn,
+                f"ALTER TABLE {table_name} ALTER COLUMN {column_name} "
+                f"TYPE NUMERIC(14,2) USING ROUND({column_name}::numeric, 2)",
+            )
+
+
+async def _migrate_add_print_archive_cost_center(conn) -> None:
+    """Add the nullable cost-center link missing from pre-billing archives."""
+    await _safe_execute(
+        conn,
+        "ALTER TABLE print_archives ADD COLUMN cost_center_id INTEGER REFERENCES cost_centers(id) ON DELETE SET NULL",
+    )
+
+
 async def run_migrations(conn):
     """Run all schema migrations and data backfills on startup.
 
@@ -1037,6 +1253,11 @@ async def run_migrations(conn):
     """
     from sqlalchemy import text
 
+    # Existing PostgreSQL databases predate the finance ORM tables. These must
+    # exist before any ALTER TABLE / CREATE INDEX statements below reference
+    # them. Fresh installs remain idempotent because create_all() runs first.
+    await _migrate_create_finance_tables(conn)
+
     # Migration: Add parent_run_id column to pipeline_runs (#1425 PR C).
     # Links a retry-failed run back to its parent so the dashboard can show
     # "Retry of run #N" inline. Idempotent on both SQLite and Postgres.
@@ -1057,6 +1278,12 @@ async def run_migrations(conn):
     # Migration: Add is_favorite column to print_archives
     await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN is_favorite BOOLEAN DEFAULT 0")
 
+    # Migration: Add wallet_charge_skipped column to print_archives so deleted print charges stay deleted
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN wallet_charge_skipped BOOLEAN DEFAULT 0")
+    else:
+        await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN wallet_charge_skipped BOOLEAN DEFAULT FALSE")
+
     # Migration: Add content_hash column to print_archives for duplicate detection
     await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN content_hash VARCHAR(64)")
 
@@ -1091,6 +1318,35 @@ async def run_migrations(conn):
     # Migration: Add is_deleted column to maintenance_types for soft-deletes
     await _safe_execute(conn, "ALTER TABLE maintenance_types ADD COLUMN is_deleted BOOLEAN DEFAULT 0")
 
+    # Migration: Add cost_center columns expected by current finance model
+    await _safe_execute(conn, "ALTER TABLE cost_centers ADD COLUMN code VARCHAR(32)")
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE cost_centers ADD COLUMN is_private BOOLEAN DEFAULT 0")
+    else:
+        await _safe_execute(conn, "ALTER TABLE cost_centers ADD COLUMN is_private BOOLEAN DEFAULT FALSE")
+    await _safe_execute(
+        conn,
+        "ALTER TABLE cost_centers ADD COLUMN owner_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL",
+    )
+    await _safe_execute(conn, "ALTER TABLE cost_centers ADD COLUMN total_budget NUMERIC(14,2)")
+    await _safe_execute(conn, "ALTER TABLE cost_centers ADD COLUMN monthly_budget NUMERIC(14,2)")
+    timestamp_type = "DATETIME" if is_sqlite() else "TIMESTAMP"
+    await _safe_execute(conn, f"ALTER TABLE cost_centers ADD COLUMN created_at {timestamp_type}")
+    await _safe_execute(conn, f"ALTER TABLE cost_centers ADD COLUMN updated_at {timestamp_type}")
+
+    # Backfill empty cost center codes on upgraded databases.
+    if is_sqlite():
+        await _safe_execute(
+            conn,
+            "UPDATE cost_centers SET code = lower(hex(randomblob(6))) WHERE code IS NULL OR trim(code) = ''",
+        )
+    else:
+        await _safe_execute(
+            conn,
+            "UPDATE cost_centers SET code = substr(md5(random()::text || clock_timestamp()::text), 1, 12) "
+            "WHERE code IS NULL OR btrim(code) = ''",
+        )
+
     # Migration: Add custom_interval_type column to printer_maintenance
     await _safe_execute(conn, "ALTER TABLE printer_maintenance ADD COLUMN custom_interval_type VARCHAR(20)")
 
@@ -1109,6 +1365,15 @@ async def run_migrations(conn):
     await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN daily_digest_enabled BOOLEAN DEFAULT 0")
     await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN daily_digest_time VARCHAR(5)")
 
+    # Migration: Add print_run_id to wallet_transactions so repeated prints of the same archive
+    # can be billed independently without mutating archive history.
+    await _safe_execute(conn, "ALTER TABLE wallet_transactions ADD COLUMN print_run_id VARCHAR(100)")
+
+    # CREATE TABLE IF NOT EXISTS is a no-op for an older, incomplete table.
+    # Delay indexes until every legacy column they reference has been added.
+    await _migrate_finance_money_to_numeric(conn)
+    await _migrate_create_finance_indexes(conn)
+
     # Migration: Add missing-spool-assignment print-start notification toggle
     try:
         async with conn.begin_nested():
@@ -1153,6 +1418,16 @@ async def run_migrations(conn):
         "CREATE UNIQUE INDEX IF NOT EXISTS uq_oidc_link_user_provider ON user_oidc_links (user_id, provider_id)",
     )
 
+    # Migration: Add unique indexes to prevent duplicate print-charge transactions
+    await _safe_execute(
+        conn,
+        "CREATE UNIQUE INDEX IF NOT EXISTS uq_wallet_transactions_print_run ON wallet_transactions (transaction_type, print_run_id)",
+    )
+    await _safe_execute(
+        conn,
+        "CREATE UNIQUE INDEX IF NOT EXISTS uq_wallet_transactions_archive ON wallet_transactions (transaction_type, print_archive_id)",
+    )
+
     # Migration: Create FTS5 virtual table for archive full-text search (SQLite only)
     # PostgreSQL uses tsvector + GIN index instead (set up in archives.py search route)
     if is_sqlite():
@@ -1307,6 +1582,20 @@ async def run_migrations(conn):
     # Migration: Add manual_start column to print_queue for staged prints
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN manual_start BOOLEAN DEFAULT 0")
 
+    # Migration: Add cost_center_id column to print_queue for billing metadata
+    try:
+        async with conn.begin_nested():
+            await conn.execute(
+                text(
+                    "ALTER TABLE print_queue ADD COLUMN cost_center_id INTEGER REFERENCES cost_centers(id) ON DELETE SET NULL"
+                )
+            )
+    except (OperationalError, ProgrammingError):
+        pass  # Already applied
+
+    # Migration: Add cost_center_id column to print_archives for billing metadata
+    await _migrate_add_print_archive_cost_center(conn)
+
     # Migration: Add wiki_url column to maintenance_types for documentation links
     await _safe_execute(conn, "ALTER TABLE maintenance_types ADD COLUMN wiki_url VARCHAR(500)")
 
@@ -1392,6 +1681,16 @@ async def run_migrations(conn):
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN nozzle_mapping TEXT")
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN nozzles_info TEXT")
 
+    # Migration: nozzle_rack_choice (#1784). Which rack position each filament
+    # group prints from, as JSON {group_id: 1-based position}. Kept separate
+    # from nozzle_mapping above because that one is BambuStudio's own expanded
+    # answer and rides to the printer verbatim, while this is the operator's
+    # pick and has to survive being re-checked against a rack that may have
+    # been re-loaded since. Also on the variants table so a batch clone does
+    # not silently lose it. Nullable TEXT, no Postgres / SQLite divergence.
+    await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN nozzle_rack_choice TEXT")
+    await _safe_execute(conn, "ALTER TABLE print_queue_variants ADD COLUMN nozzle_rack_choice TEXT")
+
     # Migration: Add target_parts_count column to projects for tracking total parts needed
     await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN target_parts_count INTEGER")
 
@@ -1419,12 +1718,15 @@ async def run_migrations(conn):
             result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='print_queue'"))
             row = result.fetchone()
             if row and "printer_id INTEGER NOT NULL" in (row[0] or ""):
+                cols_result = await conn.execute(text("PRAGMA table_info(print_queue)"))
+                col_names = {col[1] for col in cols_result.fetchall()}
                 await conn.execute(
                     text("""
                     CREATE TABLE print_queue_new (
                         id INTEGER PRIMARY KEY,
                         printer_id INTEGER REFERENCES printers(id) ON DELETE CASCADE,
                         archive_id INTEGER NOT NULL REFERENCES print_archives(id) ON DELETE CASCADE,
+                        cost_center_id INTEGER REFERENCES cost_centers(id) ON DELETE SET NULL,
                         project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL,
                         position INTEGER DEFAULT 0,
                         scheduled_time DATETIME,
@@ -1440,15 +1742,26 @@ async def run_migrations(conn):
                     )
                 """)
                 )
-                await conn.execute(
-                    text("""
+                if "cost_center_id" in col_names:
+                    await conn.execute(
+                        text("""
                     INSERT INTO print_queue_new
-                    SELECT id, printer_id, archive_id, project_id, position, scheduled_time,
+                    SELECT id, printer_id, archive_id, cost_center_id, project_id, position, scheduled_time,
                            manual_start, require_previous_success, auto_off_after, ams_mapping,
                            status, started_at, completed_at, error_message, created_at
                     FROM print_queue
                 """)
-                )
+                    )
+                else:
+                    await conn.execute(
+                        text("""
+                    INSERT INTO print_queue_new
+                    SELECT id, printer_id, archive_id, NULL, project_id, position, scheduled_time,
+                           manual_start, require_previous_success, auto_off_after, ams_mapping,
+                           status, started_at, completed_at, error_message, created_at
+                    FROM print_queue
+                """)
+                    )
                 await conn.execute(text("DROP TABLE print_queue"))
                 await conn.execute(text("ALTER TABLE print_queue_new RENAME TO print_queue"))
         except (OperationalError, ProgrammingError):
@@ -1652,6 +1965,8 @@ async def run_migrations(conn):
             result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='print_queue'"))
             row = result.fetchone()
             if row and "archive_id INTEGER NOT NULL" in (row[0] or ""):
+                cols_result = await conn.execute(text("PRAGMA table_info(print_queue)"))
+                col_names = {col[1] for col in cols_result.fetchall()}
                 await conn.execute(
                     text("""
                     CREATE TABLE print_queue_new2 (
@@ -1659,6 +1974,7 @@ async def run_migrations(conn):
                         printer_id INTEGER REFERENCES printers(id) ON DELETE CASCADE,
                         archive_id INTEGER REFERENCES print_archives(id) ON DELETE CASCADE,
                         library_file_id INTEGER REFERENCES library_files(id) ON DELETE CASCADE,
+                        cost_center_id INTEGER REFERENCES cost_centers(id) ON DELETE SET NULL,
                         project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL,
                         position INTEGER DEFAULT 0,
                         scheduled_time DATETIME,
@@ -1681,17 +1997,30 @@ async def run_migrations(conn):
                     )
                 """)
                 )
-                await conn.execute(
-                    text("""
+                if "cost_center_id" in col_names:
+                    await conn.execute(
+                        text("""
                     INSERT INTO print_queue_new2
-                    SELECT id, printer_id, archive_id, NULL, project_id, position, scheduled_time,
+                    SELECT id, printer_id, archive_id, NULL, cost_center_id, project_id, position, scheduled_time,
                            manual_start, require_previous_success, auto_off_after, ams_mapping, plate_id,
                            COALESCE(bed_levelling, 1), COALESCE(flow_cali, 0), COALESCE(vibration_cali, 1),
                            COALESCE(layer_inspect, 0), COALESCE(timelapse, 0), COALESCE(use_ams, 1),
                            status, started_at, completed_at, error_message, created_at
                     FROM print_queue
                 """)
-                )
+                    )
+                else:
+                    await conn.execute(
+                        text("""
+                    INSERT INTO print_queue_new2
+                    SELECT id, printer_id, archive_id, NULL, NULL, project_id, position, scheduled_time,
+                           manual_start, require_previous_success, auto_off_after, ams_mapping, plate_id,
+                           COALESCE(bed_levelling, 1), COALESCE(flow_cali, 0), COALESCE(vibration_cali, 1),
+                           COALESCE(layer_inspect, 0), COALESCE(timelapse, 0), COALESCE(use_ams, 1),
+                           status, started_at, completed_at, error_message, created_at
+                    FROM print_queue
+                """)
+                    )
                 await conn.execute(text("DROP TABLE print_queue"))
                 await conn.execute(text("ALTER TABLE print_queue_new2 RENAME TO print_queue"))
         except (OperationalError, ProgrammingError):
@@ -2045,6 +2374,7 @@ async def run_migrations(conn):
             layer_usage TEXT,
             filament_properties TEXT,
             tray_remain_start TEXT,
+            tray_now_at_start INTEGER,
             UNIQUE(printer_id, archive_id)
         )
         """
@@ -2060,6 +2390,7 @@ async def run_migrations(conn):
             layer_usage TEXT,
             filament_properties TEXT,
             tray_remain_start TEXT,
+            tray_now_at_start INTEGER,
             UNIQUE(printer_id, archive_id)
         )
         """,
@@ -2068,6 +2399,18 @@ async def run_migrations(conn):
     # the original schema: add tray_remain_start, and relax filament_usage's
     # NOT NULL so the no-3MF branch can persist a remain-only tracking row.
     await _safe_execute(conn, "ALTER TABLE active_print_spoolman ADD COLUMN tray_remain_start TEXT")
+    # Which slot the print was drawing from at the start, so the remain%-delta
+    # fallback can tell a slot this print used from one it never touched
+    # (#1820). Nullable, because a row written mid-upgrade has no answer to
+    # give. INTEGER is spelled the same either way; the branch is only for
+    # IF NOT EXISTS, which SQLite's ALTER TABLE does not accept.
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE active_print_spoolman ADD COLUMN tray_now_at_start INTEGER")
+    else:
+        await _safe_execute(
+            conn,
+            "ALTER TABLE active_print_spoolman ADD COLUMN IF NOT EXISTS tray_now_at_start INTEGER",
+        )
     if is_sqlite():
         # SQLite can't ALTER COLUMN; patch sqlite_master directly. Mirrors the
         # users.password_hash NULL-relaxation a few hundred lines below — see
@@ -2618,6 +2961,29 @@ async def run_migrations(conn):
     except (OperationalError, ProgrammingError):
         pass
 
+    # Migration (#342): batch orders — planning metadata on print_batches. The
+    # per-plate target rows live in their own table, created by create_all().
+    await _safe_execute(
+        conn, "ALTER TABLE print_batches ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL"
+    )
+    await _safe_execute(conn, "ALTER TABLE print_batches ADD COLUMN notes TEXT")
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE print_batches ADD COLUMN due_date DATETIME")
+        await _safe_execute(conn, "ALTER TABLE print_batches ADD COLUMN completed_at DATETIME")
+    else:
+        await _safe_execute(conn, "ALTER TABLE print_batches ADD COLUMN due_date TIMESTAMP")
+        await _safe_execute(conn, "ALTER TABLE print_batches ADD COLUMN completed_at TIMESTAMP")
+
+    # Migration (#342): attribute a logged run to the queue item that produced
+    # it, so batch cost/energy can be summed without guessing from archive_id.
+    await _safe_execute(
+        conn,
+        "ALTER TABLE print_log_entries ADD COLUMN queue_item_id INTEGER REFERENCES print_queue(id) ON DELETE SET NULL",
+    )
+    await _safe_execute(
+        conn, "CREATE INDEX IF NOT EXISTS ix_print_log_entries_queue_item_id ON print_log_entries (queue_item_id)"
+    )
+
     # Migration: Shortest-job-first scheduling columns on print_queue
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN print_time_seconds INTEGER")
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN been_jumped BOOLEAN DEFAULT FALSE NOT NULL")
@@ -2625,6 +2991,39 @@ async def run_migrations(conn):
     # Migration: Auto-print G-code injection (#422)
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN gcode_injection BOOLEAN DEFAULT FALSE NOT NULL")
 
+    # Migration: Store estimated print cost for budget checks before queued jobs start
+    await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN estimated_cost FLOAT")
+    await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN billing_run_id VARCHAR(36)")
+    await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN billing_run_id VARCHAR(36)")
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE wallet_transactions ADD COLUMN is_voided BOOLEAN DEFAULT 0 NOT NULL")
+    else:
+        await _safe_execute(conn, "ALTER TABLE wallet_transactions ADD COLUMN is_voided BOOLEAN DEFAULT FALSE NOT NULL")
+    await _safe_execute(
+        conn,
+        "CREATE INDEX IF NOT EXISTS ix_wallet_transactions_is_voided ON wallet_transactions (is_voided)",
+    )
+    if is_sqlite():
+        await _safe_execute(
+            conn,
+            "ALTER TABLE notification_providers ADD COLUMN on_billing_charge_failed BOOLEAN DEFAULT 1",
+        )
+    else:
+        await _safe_execute(
+            conn,
+            "ALTER TABLE notification_providers ADD COLUMN on_billing_charge_failed BOOLEAN DEFAULT TRUE",
+        )
+
+    # Reprints reuse their source archive, so archive uniqueness must only be
+    # the legacy fallback for rows without a per-run UUID. The globally unique
+    # print_run_id is the idempotency key for all new charges.
+    await _safe_execute(conn, "DROP INDEX IF EXISTS uq_wallet_transactions_archive")
+    await _safe_execute(
+        conn,
+        "CREATE UNIQUE INDEX IF NOT EXISTS uq_wallet_transactions_archive"
+        " ON wallet_transactions (transaction_type, print_archive_id) WHERE print_run_id IS NULL",
+    )
+
     # Migration: Add backup_spools and backup_archives columns to github_backup_config
     await _safe_execute(conn, "ALTER TABLE github_backup_config ADD COLUMN backup_spools BOOLEAN DEFAULT 0")
     await _safe_execute(conn, "ALTER TABLE github_backup_config ADD COLUMN backup_archives BOOLEAN DEFAULT 0")
@@ -3949,6 +4348,143 @@ async def run_migrations(conn):
             conn, "ALTER TABLE notification_providers ADD COLUMN on_plate_clear_required BOOLEAN DEFAULT false"
         )
 
+    # Migration: variant grouping for library files (#671 / #2570). The
+    # `file_variant_groups` table itself needs no migration — create_all() above
+    # builds it — but the two member-side columns do. INTEGER and the inline
+    # REFERENCES clause are spelled identically on SQLite and Postgres, and
+    # SQLite accepts a REFERENCES on ADD COLUMN (same form as the
+    # pipeline_runs.parent_run_id migration at the top of this function).
+    await _safe_execute(
+        conn,
+        "ALTER TABLE library_files ADD COLUMN variant_group_id INTEGER "
+        "REFERENCES file_variant_groups(id) ON DELETE SET NULL",
+    )
+    await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN variant_position INTEGER DEFAULT 0")
+    # User-declared target model for a file whose 3MF does not say (#671).
+    # VARCHAR(50) is spelled identically on SQLite and Postgres.
+    await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN variant_target_model VARCHAR(50)")
+    # The model declares index=True, so fresh installs get this from create_all();
+    # migrated databases need it spelled out. Resolution looks members up by group
+    # on every scheduler pass that touches a grouped item.
+    await _safe_execute(
+        conn,
+        "CREATE INDEX IF NOT EXISTS ix_library_files_variant_group_id ON library_files (variant_group_id)",
+    )
+    await _migrate_backfill_variant_groups(conn)
+
+    # Migration: Home Assistant sensor alerts (#1148). The printer_ha_sensors
+    # table itself is new, so create_all() builds it; only the provider opt-in
+    # column needs adding to existing databases.
+    #
+    # DEFAULT FALSE, not DEFAULT 0: Postgres will not take an integer default
+    # for a boolean column, and _safe_execute swallows the DatatypeMismatchError
+    # — so the older "BOOLEAN DEFAULT 0" migrations above quietly do nothing on
+    # Postgres and only work there because create_all() builds the column on a
+    # fresh install. SQLite has understood FALSE since 3.23, so this spelling
+    # is the one that actually applies on both.
+    await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_ha_sensor_alert BOOLEAN DEFAULT FALSE")
+
+    # Migration: auto-drying-suspended notification opt-in (#2770). Defaults ON:
+    # it fires at most once per AMS unit, and only to say Bambuddy has STOPPED
+    # doing something it was doing before — silence there reads as "still
+    # drying" and is exactly how the reporter lost two days to a re-arm loop.
+    await _safe_execute(
+        conn, "ALTER TABLE notification_providers ADD COLUMN on_ams_drying_suspended BOOLEAN DEFAULT TRUE"
+    )
+
+
+async def _migrate_backfill_variant_groups(conn) -> None:
+    """Build variant groups from the slice provenance already on disk (#671 / #2570).
+
+    ``sliced_from_library_file_id`` has been stamped into ``file_metadata`` by the
+    Slice button (routes/library.py) and the pipeline runner (routes/pipeline_runs.py)
+    since those features shipped, and until now nothing ever read it back — the
+    link existed but was inert. This promotes it to real group membership so an
+    existing library arrives with its slice sets already grouped instead of
+    requiring the user to re-declare by hand what Bambuddy itself recorded.
+
+    Only sources with **two or more** sliced children carrying **distinct**
+    ``sliced_for_model`` values produce a group:
+
+    - Fewer than two candidates is not a choice, and a one-member group would
+      change nothing at print time while creating a row per sliced file in every
+      library on earth.
+    - Two children sliced for the same printer are not alternatives — the
+      resolver has no basis to prefer one, so grouping them would turn a
+      harmless duplicate into an arbitrary pick. Those sources are skipped
+      whole; the user can still group them by hand and choose an order.
+
+    The unsliced source file is deliberately not a member. It has no
+    ``sliced_for_model``, so it can never be a dispatch candidate; showing it
+    alongside its variants is a File Manager listing concern, which is out of
+    scope.
+
+    Idempotent: only files with no group yet are considered, so a re-run after a
+    partial apply resumes rather than duplicating, and a user who has since
+    ungrouped files by hand does not get them silently regrouped.
+    """
+    from sqlalchemy import text
+
+    from backend.app.models.library import FileVariantGroup
+
+    if is_sqlite():
+        source_expr = "json_extract(file_metadata, '$.sliced_from_library_file_id')"
+        model_expr = "json_extract(file_metadata, '$.sliced_for_model')"
+    else:
+        # file_metadata is JSON, not JSONB — cast before using the -> operators,
+        # matching _migrate_drop_library_print_name above.
+        source_expr = "file_metadata::jsonb->>'sliced_from_library_file_id'"
+        model_expr = "file_metadata::jsonb->>'sliced_for_model'"
+
+    async with conn.begin_nested():
+        # nosec B608 — the only interpolated fragments are the two dialect
+        # literals assigned directly above; both branches are constants and no
+        # caller value reaches this string. They are JSON *expressions*, not
+        # values, so a bind parameter cannot express them.
+        rows = (
+            await conn.execute(
+                text(
+                    f"SELECT id, {source_expr} AS source_id, {model_expr} AS model "  # nosec B608
+                    "FROM library_files "
+                    f"WHERE {source_expr} IS NOT NULL AND {model_expr} IS NOT NULL "
+                    "AND variant_group_id IS NULL AND deleted_at IS NULL "
+                    "ORDER BY id"
+                )
+            )
+        ).fetchall()
+
+        by_source: dict[str, list[tuple[int, str]]] = {}
+        for file_id, source_id, model in rows:
+            by_source.setdefault(str(source_id), []).append((file_id, str(model)))
+
+        for source_id, members in by_source.items():
+            if len(members) < 2:
+                continue
+            models = [m for _, m in members]
+            if len(set(models)) != len(models):
+                # Same printer sliced twice — ambiguous, leave it to the user.
+                continue
+
+            # Name the group after the source file when it is still around; its
+            # filename is what the user recognises. A deleted source leaves the
+            # variants perfectly usable, so fall back rather than skip.
+            name_row = (
+                await conn.execute(
+                    text("SELECT filename FROM library_files WHERE id = :sid"),
+                    {"sid": int(source_id)},
+                )
+            ).fetchone()
+            group_name = name_row[0] if name_row else f"{members[0][1]} + {len(members) - 1} more"
+
+            result = await conn.execute(FileVariantGroup.__table__.insert().values(name=group_name))
+            group_id = result.inserted_primary_key[0]
+
+            for position, (file_id, _model) in enumerate(members):
+                await conn.execute(
+                    text("UPDATE library_files SET variant_group_id = :gid, variant_position = :pos WHERE id = :fid"),
+                    {"gid": group_id, "pos": position, "fid": file_id},
+                )
+
 
 _USER_PRINT_TEMPLATE_RENAMES: tuple[tuple[str, str, str], ...] = (
     ("user_print_start", "User Print Started", "User Print Started Email"),
@@ -4082,6 +4618,18 @@ async def seed_default_groups():
         "library:read": "library:read_own",
     }
 
+    FINANCE_PERMISSION_MIGRATION = {
+        "finance:read_own": "cost_centers:read_own",
+        "finance:read_all": "cost_centers:read_all",
+        "finance:transactions:create": "cost_centers:modify",
+        "finance:create_transactions": "cost_centers:modify",
+        "finance:createTransactions:create": "cost_centers:modify",
+        "finance:cost_centers:create": "cost_centers:create",
+        "finance:cost_centers:update": "cost_centers:modify",
+        "finance:cost_centers:assign_users": "cost_centers:modify",
+        "finance:budgets:update": "cost_centers:modify",
+    }
+
     async with async_session() as session:
         # Get existing groups
         result = await session.execute(select(Group))
@@ -4122,6 +4670,16 @@ async def seed_default_groups():
                                 "Migrated permission '%s' to '%s' in group '%s'", old_perm, new_perm, group_name
                             )
 
+                    for old_perm, new_perm in FINANCE_PERMISSION_MIGRATION.items():
+                        if old_perm in new_permissions:
+                            new_permissions.remove(old_perm)
+                            if new_perm not in new_permissions:
+                                new_permissions.append(new_perm)
+                            updated = True
+                            logger.info(
+                                "Migrated permission '%s' to '%s' in group '%s'", old_perm, new_perm, group_name
+                            )
+
                     # For Administrators, also ensure they get *_all permissions if they have any new *_own
                     if group_name == "Administrators":
                         for _own_perm, all_perm in [
@@ -4373,3 +4931,93 @@ async def seed_color_catalog():
             )
         await session.commit()
         logger.info("Seeded %d default color catalog entries", len(DEFAULT_COLOR_CATALOG))
+
+
+async def repair_wallet_ledger_internal(session: AsyncSession):
+    """Internal helper that repairs wallet ledger using an existing session.
+
+    Used by API endpoints that need to rebuild the ledger within their own transaction.
+    """
+    from sqlalchemy import bindparam, select
+
+    from backend.app.models.finance import CostCenter, UserWallet, WalletTransaction
+    from backend.app.services.finance_balance import transaction_affects_personal_balance
+
+    center_rows = await session.execute(select(CostCenter.id, CostCenter.is_private, CostCenter.owner_user_id))
+    centers = {
+        int(center_id): (bool(is_private), owner_user_id) for center_id, is_private, owner_user_id in center_rows
+    }
+
+    # Build running balances per (user, cost_center_id) pair
+    cc_running_balances: dict[int, float] = {}  # cost_center_id -> running balance
+    user_personal_balances: dict[int, float] = {}  # user_id -> personal running balance
+
+    updated_count = 0
+    batch_size = 1000
+    batch_offset = 0
+    while True:
+        rows = (
+            await session.execute(
+                select(
+                    WalletTransaction.id,
+                    WalletTransaction.user_id,
+                    WalletTransaction.cost_center_id,
+                    WalletTransaction.amount,
+                    WalletTransaction.balance_after,
+                )
+                .where(WalletTransaction.is_voided.is_(False))
+                .order_by(WalletTransaction.created_at.asc(), WalletTransaction.id.asc())
+                .offset(batch_offset)
+                .limit(batch_size)
+            )
+        ).all()
+        if not rows:
+            break
+
+        updates: list[dict[str, object]] = []
+        for transaction_id, user_id, cost_center_id, amount, balance_after in rows:
+            amount_value = float(amount)
+            center_is_private, center_owner_user_id = centers.get(cost_center_id, (False, None))
+            affects_personal = transaction_affects_personal_balance(
+                user_id,
+                cost_center_id,
+                is_private=center_is_private,
+                owner_user_id=center_owner_user_id,
+            )
+            if cost_center_id is None:
+                new_balance = round(user_personal_balances.get(user_id, 0.0) + amount_value, 2)
+                user_personal_balances[user_id] = new_balance
+            else:
+                new_balance = round(cc_running_balances.get(cost_center_id, 0.0) + amount_value, 2)
+                cc_running_balances[cost_center_id] = new_balance
+                if affects_personal:
+                    user_personal_balances[user_id] = round(
+                        user_personal_balances.get(user_id, 0.0) + amount_value,
+                        2,
+                    )
+
+            if balance_after is None or round(float(balance_after), 2) != new_balance:
+                updates.append({"_transaction_id": transaction_id, "_balance_after": new_balance})
+
+        if updates:
+            statement = (
+                WalletTransaction.__table__.update()
+                .where(WalletTransaction.__table__.c.id == bindparam("_transaction_id"))
+                .values(balance_after=bindparam("_balance_after"))
+            )
+            await session.execute(statement, updates)
+            updated_count += len(updates)
+        batch_offset += len(rows)
+
+    # Update every wallet, including stale wallets whose canonical balance is
+    # now zero because their last personal transaction was deleted.
+    wallet_result = await session.execute(select(UserWallet))
+    for wallet in wallet_result.scalars().all():
+        balance = round(user_personal_balances.get(wallet.user_id, 0.0), 2)
+        if wallet.balance != balance:
+            wallet.balance = balance
+            session.add(wallet)
+            updated_count += 1
+
+    await session.flush()
+    return updated_count

+ 20 - 0
backend/app/core/permissions.py

@@ -139,6 +139,12 @@ class Permission(StrEnum):
     STATS_READ = "stats:read"
     STATS_FILTER_BY_USER = "stats:filter_by_user"
 
+    # Cost Centers
+    COST_CENTERS_READ_OWN = "cost_centers:read_own"
+    COST_CENTERS_READ_ALL = "cost_centers:read_all"
+    COST_CENTERS_MODIFY = "cost_centers:modify"
+    COST_CENTERS_CREATE = "cost_centers:create"
+
     # System Info
     SYSTEM_READ = "system:read"
 
@@ -168,6 +174,11 @@ class Permission(StrEnum):
 
     # Users (admin-level)
     USERS_READ = "users:read"
+    # Narrow read: id + username only, no emails/roles/groups/permissions (#1894).
+    # Exists so an id -> name mapping can be resolved without handing out the
+    # full user objects. Pairs with STATS_FILTER_BY_USER, which is useless
+    # without a way to discover the ids it filters on.
+    USERS_READ_SLIM = "users:read_slim"
     USERS_CREATE = "users:create"
     USERS_UPDATE = "users:update"
     USERS_DELETE = "users:delete"
@@ -305,6 +316,12 @@ PERMISSION_CATEGORIES = {
         Permission.STATS_READ,
         Permission.STATS_FILTER_BY_USER,
     ],
+    "Finance": [
+        Permission.COST_CENTERS_READ_OWN,
+        Permission.COST_CENTERS_READ_ALL,
+        Permission.COST_CENTERS_MODIFY,
+        Permission.COST_CENTERS_CREATE,
+    ],
     "System": [
         Permission.SYSTEM_READ,
     ],
@@ -334,6 +351,7 @@ PERMISSION_CATEGORIES = {
     ],
     "User Management": [
         Permission.USERS_READ,
+        Permission.USERS_READ_SLIM,
         Permission.USERS_CREATE,
         Permission.USERS_UPDATE,
         Permission.USERS_DELETE,
@@ -460,6 +478,8 @@ DEFAULT_GROUPS = {
             Permission.PRINTER_SENSOR_HISTORY_READ.value,
             Permission.STATS_READ.value,
             Permission.SYSTEM_READ.value,
+            # Finance - own visibility
+            Permission.COST_CENTERS_READ_OWN.value,
             # Settings - read only
             Permission.SETTINGS_READ.value,
             # Slicer Pipelines - full access

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 662 - 68
backend/app/main.py


+ 6 - 2
backend/app/models/__init__.py

@@ -8,7 +8,7 @@ from backend.app.models.filament import Filament
 from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
 from backend.app.models.group import Group, user_groups
 from backend.app.models.kprofile_note import KProfileNote
-from backend.app.models.library import LibraryFile, LibraryFolder
+from backend.app.models.library import FileVariantGroup, LibraryFile, LibraryFolder
 from backend.app.models.local_preset import LocalPreset
 from backend.app.models.location import Location
 from backend.app.models.long_lived_token import LongLivedToken
@@ -19,8 +19,9 @@ from backend.app.models.oidc_provider import OIDCProvider, UserOIDCLink
 from backend.app.models.orca_base_cache import OrcaBaseProfile
 from backend.app.models.pending_upload import PendingUpload
 from backend.app.models.pipeline_run import PipelineJob, PipelineRun
-from backend.app.models.print_batch import PrintBatch
+from backend.app.models.print_batch import PrintBatch, PrintBatchPlate
 from backend.app.models.printer import Printer
+from backend.app.models.printer_ha_sensor import PrinterHASensor
 from backend.app.models.printer_sensor_history import PrinterSensorHistory
 from backend.app.models.project import Project
 from backend.app.models.settings import Settings
@@ -56,11 +57,14 @@ __all__ = [
     "APIKey",
     "AMSSensorHistory",
     "PrinterSensorHistory",
+    "PrinterHASensor",
     "AmsLabel",
     "PendingUpload",
     "PrintBatch",
+    "PrintBatchPlate",
     "LibraryFolder",
     "LibraryFile",
+    "FileVariantGroup",
     "Location",
     "User",
     "Group",

+ 57 - 0
backend/app/models/active_print_session.py

@@ -0,0 +1,57 @@
+"""Durable copy of the filament-attribution context for an in-flight print."""
+
+from datetime import datetime
+
+from sqlalchemy import JSON, DateTime, ForeignKey
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class ActivePrintSession(Base):
+    """Print-start context the completion path needs, persisted per printer.
+
+    ``usage_tracker._active_sessions`` holds the same data in memory, and the
+    tray-change log lives on ``PrinterState``. Both are lost when Bambuddy
+    restarts mid-print, which on a long print silently destroys filament
+    attribution: without the plate the 3MF parser sums every plate, without the
+    assignment snapshot a spool unlinked at runout can't be resolved, and
+    without the tray-change log an AMS-backup switch charges the whole print to
+    whichever tray happened to finish it.
+
+    One row per printer — a printer runs one print at a time. Written at print
+    start, appended to on every tray change, deleted at completion. A leaked
+    row (completion missed entirely) is harmless: print start overwrites it,
+    and the completion path ignores a row whose ``started_at`` doesn't line up
+    with the print it is closing.
+
+    The Spoolman writer has had an equivalent durable row since #1820
+    (``active_print_spoolman``); this is the internal-inventory counterpart.
+    """
+
+    __tablename__ = "active_print_sessions"
+
+    printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"), primary_key=True)
+
+    print_name: Mapped[str] = mapped_column(default="")
+    started_at: Mapped[datetime] = mapped_column(DateTime)
+
+    # tray_now at print start — reliable, unlike at completion where the
+    # printer has usually retracted and reports 255.
+    tray_now_at_start: Mapped[int] = mapped_column(default=-1)
+
+    # Queue item's plate for multi-plate 3MFs dispatched one plate at a time.
+    plate_id: Mapped[int | None] = mapped_column(nullable=True)
+
+    # Slicer slot -> global tray, as dispatched: [2]
+    ams_mapping: Mapped[list | None] = mapped_column(JSON, nullable=True)
+
+    # {"<ams_id>-<tray_id>": spool_id} — the assignment map as it stood before
+    # the print could disturb it.
+    spool_assignments: Mapped[dict | None] = mapped_column(JSON, nullable=True)
+
+    # {"<ams_id>-<tray_id>": remain%} for the remain-delta fallback path.
+    tray_remain_start: Mapped[dict | None] = mapped_column(JSON, nullable=True)
+
+    # [[global_tray_id, layer_num], ...] mirroring PrinterState.tray_change_log.
+    tray_change_log: Mapped[list | None] = mapped_column(JSON, nullable=True)

+ 11 - 0
backend/app/models/active_print_spoolman.py

@@ -51,3 +51,14 @@ class ActivePrintSpoolman(Base):
     # ``tray_remain_start`` snapshot at usage_tracker.py:301.
     # Format: {"<ams_id>-<tray_id>": {"remain": int, "tray_uuid": str}, ...}
     tray_remain_start: Mapped[dict | None] = mapped_column(JSON, nullable=True)
+
+    # Global tray id the printer was drawing from when the print started.
+    # Evidence of which slot the print actually used, for the remain%-delta
+    # fallback (#1269 on the internal side, #1820 here): without it, a spool
+    # swapped in an untouched slot mid-print reads as consumption and is
+    # charged to whatever spool that slot was assigned. Often the only
+    # evidence there is — a print started from the printer's own screen
+    # carries no ams_mapping and may change tray never. Nullable: rows written
+    # before this column existed, and printers that report no tray_now, simply
+    # provide no evidence and are handled as before.
+    tray_now_at_start: Mapped[int | None] = mapped_column(nullable=True)

+ 9 - 0
backend/app/models/archive.py

@@ -18,6 +18,9 @@ class PrintArchive(Base):
     library_file_id: Mapped[int | None] = mapped_column(
         ForeignKey("library_files.id", ondelete="SET NULL"), nullable=True
     )
+    cost_center_id: Mapped[int | None] = mapped_column(
+        ForeignKey("cost_centers.id", ondelete="SET NULL"), nullable=True
+    )
 
     # File info
     filename: Mapped[str] = mapped_column(String(255))
@@ -71,6 +74,9 @@ class PrintArchive(Base):
     # if the same subtask_id reappears after restart, we know it's the same
     # print and keep the original row instead of cancel-then-create.
     subtask_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
+    # Durable Bambuddy UUID for billing idempotency. Unlike subtask_id, this is
+    # not constrained by printer firmware and is replaced for every reprint.
+    billing_run_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
 
     # Which plate of a multi-plate 3MF this print was for (1-based), copied from
     # the queue item at dispatch (#2603). A whole multi-plate 3MF is uploaded
@@ -92,6 +98,7 @@ class PrintArchive(Base):
 
     # User additions
     is_favorite: Mapped[bool] = mapped_column(Boolean, default=False)
+    wallet_charge_skipped: Mapped[bool] = mapped_column(Boolean, default=False)
     tags: Mapped[str | None] = mapped_column(Text)
     notes: Mapped[str | None] = mapped_column(Text)
     cost: Mapped[float | None] = mapped_column(Float)
@@ -122,9 +129,11 @@ class PrintArchive(Base):
     # Relationships
     printer: Mapped["Printer | None"] = relationship(back_populates="archives")
     project: Mapped["Project | None"] = relationship(back_populates="archives")
+    cost_center: Mapped["CostCenter | None"] = relationship()
     created_by: Mapped["User | None"] = relationship()
 
 
+from backend.app.models.finance import CostCenter  # noqa: E402, F811
 from backend.app.models.printer import Printer  # noqa: E402, F811
 from backend.app.models.project import Project  # noqa: E402, F811
 from backend.app.models.user import User  # noqa: E402, F811

+ 166 - 0
backend/app/models/finance.py

@@ -0,0 +1,166 @@
+from __future__ import annotations
+
+import uuid
+from datetime import datetime
+from enum import Enum as PyEnum
+from typing import TYPE_CHECKING
+
+from sqlalchemy import Boolean, CheckConstraint, DateTime, ForeignKey, Numeric, String, Text, UniqueConstraint, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
+
+from backend.app.core.database import Base
+
+if TYPE_CHECKING:
+    from backend.app.models.archive import PrintArchive
+    from backend.app.models.print_queue import PrintQueueItem
+    from backend.app.models.user import User
+
+
+class TransactionType(str, PyEnum):
+    PRINT_CHARGE = "print_charge"
+    DEPOSIT = "deposit"
+    WITHDRAW = "withdraw"
+    MANUAL_ADJUSTMENT = "manual_adjustment"
+
+
+VALID_TRANSACTION_TYPES = {item.value for item in TransactionType}
+
+
+def normalize_transaction_type(value: str | TransactionType) -> str:
+    if isinstance(value, TransactionType):
+        return value.value
+    if value not in VALID_TRANSACTION_TYPES:
+        raise ValueError(f"Invalid transaction type: {value}")
+    return value
+
+
+class UserWallet(Base):
+    """Per-user wallet balance.
+
+    Balance updates are driven by wallet transactions.
+    """
+
+    __tablename__ = "user_wallets"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), unique=True, index=True)
+    balance: Mapped[float] = mapped_column(Numeric(14, 2, asdecimal=False), default=0.0)
+    currency: Mapped[str] = mapped_column(String(3), default="EUR")
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
+
+    user: Mapped[User] = relationship()
+
+
+class CostCenter(Base):
+    """Cost center for assigning print costs and budgets."""
+
+    __tablename__ = "cost_centers"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    code: Mapped[str] = mapped_column(String(32), unique=True, index=True, default=lambda: uuid.uuid4().hex[:12])
+    name: Mapped[str] = mapped_column(String(150), index=True)
+    is_active: Mapped[bool] = mapped_column(Boolean, default=True)
+    is_private: Mapped[bool] = mapped_column(Boolean, default=False)
+    owner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
+
+    total_budget: Mapped[float | None] = mapped_column(Numeric(14, 2, asdecimal=False), nullable=True)
+    monthly_budget: Mapped[float | None] = mapped_column(Numeric(14, 2, asdecimal=False), nullable=True)
+
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
+
+    owner: Mapped[User | None] = relationship()
+    members: Mapped[list[CostCenterMember]] = relationship(
+        "CostCenterMember",
+        back_populates="cost_center",
+        cascade="all, delete-orphan",
+        lazy="selectin",
+    )
+
+
+class CostCenterMember(Base):
+    """User-to-cost-center assignment with print permission."""
+
+    __tablename__ = "cost_center_members"
+    __table_args__ = (UniqueConstraint("cost_center_id", "user_id", name="uq_cost_center_members_cc_user"),)
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    cost_center_id: Mapped[int] = mapped_column(ForeignKey("cost_centers.id", ondelete="CASCADE"), index=True)
+    user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
+    can_print: Mapped[bool] = mapped_column(Boolean, default=True)
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+
+    cost_center: Mapped[CostCenter] = relationship("CostCenter", back_populates="members")
+    user: Mapped[User] = relationship()
+
+
+class BudgetReservation(Base):
+    """Persisted budget hold for accepted print work that has not been charged yet."""
+
+    __tablename__ = "budget_reservations"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    cost_center_id: Mapped[int] = mapped_column(ForeignKey("cost_centers.id", ondelete="CASCADE"), index=True)
+    amount: Mapped[float] = mapped_column(Numeric(14, 2, asdecimal=False))
+    status: Mapped[str] = mapped_column(String(20), default="active", index=True)
+    source_type: Mapped[str] = mapped_column(String(50), index=True)
+    source_id: Mapped[int | None] = mapped_column(index=True)
+    print_archive_id: Mapped[int | None] = mapped_column(
+        ForeignKey("print_archives.id", ondelete="SET NULL"), nullable=True, index=True
+    )
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    released_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+
+    cost_center: Mapped[CostCenter] = relationship()
+    print_archive: Mapped[PrintArchive | None] = relationship()
+
+
+class WalletTransaction(Base):
+    """Immutable wallet ledger entry."""
+
+    __tablename__ = "wallet_transactions"
+    __table_args__ = (
+        CheckConstraint(
+            "transaction_type IN ('print_charge', 'deposit', 'withdraw', 'manual_adjustment')",
+            name="ck_wallet_transactions_transaction_type",
+        ),
+    )
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
+    cost_center_id: Mapped[int | None] = mapped_column(
+        ForeignKey("cost_centers.id", ondelete="SET NULL"), nullable=True, index=True
+    )
+
+    transaction_type: Mapped[str] = mapped_column(String(40), index=True)
+    amount: Mapped[float] = mapped_column(Numeric(14, 2, asdecimal=False))
+    balance_after: Mapped[float | None] = mapped_column(Numeric(14, 2, asdecimal=False), nullable=True)
+    description: Mapped[str | None] = mapped_column(Text, nullable=True)
+
+    created_by_user_id: Mapped[int | None] = mapped_column(
+        ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
+    )
+    print_run_id: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
+    print_archive_id: Mapped[int | None] = mapped_column(
+        ForeignKey("print_archives.id", ondelete="SET NULL"), nullable=True, index=True
+    )
+    print_queue_id: Mapped[int | None] = mapped_column(
+        ForeignKey("print_queue.id", ondelete="SET NULL"), nullable=True, index=True
+    )
+    # Voided ledger rows stay persisted as run-scoped idempotency tombstones.
+    # They are excluded from balances and API listings, but their print_run_id
+    # prevents a delayed duplicate completion callback from recreating a charge
+    # that an administrator deliberately removed.
+    is_voided: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
+
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), index=True)
+
+    user: Mapped[User] = relationship(foreign_keys=[user_id])
+    cost_center: Mapped[CostCenter | None] = relationship()
+    created_by: Mapped[User | None] = relationship(foreign_keys=[created_by_user_id])
+    print_archive: Mapped[PrintArchive | None] = relationship()
+    print_queue: Mapped[PrintQueueItem | None] = relationship()
+
+    @validates("transaction_type")
+    def _validate_transaction_type(self, key: str, value: str | TransactionType) -> str:
+        return normalize_transaction_type(value)

+ 1 - 1
backend/app/models/github_backup.py

@@ -59,7 +59,7 @@ class GitHubBackupLog(Base):
     started_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
     status: Mapped[str] = mapped_column(String(20))  # running/success/failed/skipped
-    trigger: Mapped[str] = mapped_column(String(20))  # manual/scheduled
+    trigger: Mapped[str] = mapped_column(String(20))  # manual/scheduled/restore
 
     commit_sha: Mapped[str | None] = mapped_column(String(40), nullable=True)
     files_changed: Mapped[int] = mapped_column(Integer, default=0)

+ 53 - 0
backend/app/models/library.py

@@ -60,6 +60,41 @@ class LibraryFolder(Base):
     archive: Mapped["PrintArchive | None"] = relationship()
 
 
+class FileVariantGroup(Base):
+    """A set of library files that are the same job sliced for different printers.
+
+    Members are peers, not a source/output hierarchy. The group answers one
+    question — "which of these files goes to an H2S, and which to an H2C" — and
+    both open features need that answer from opposite ends: the print queue
+    picks the printer and needs the matching file (#671), the File Manager's
+    print action has the printer already and needs the same match (#2570).
+
+    The group deliberately stores no model information of its own. Each
+    member's target model comes from its own ``file_metadata['sliced_for_model']``,
+    parsed out of the 3MF, so a group can never disagree with the files it
+    contains. It also carries no pointer to an unsliced source file: that is a
+    display concern for the grouped File Manager listing, which is not built.
+
+    Deleting a group ungroups its files rather than deleting them (the member
+    side is ON DELETE SET NULL) — every member is independently printable.
+    """
+
+    __tablename__ = "file_variant_groups"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    name: Mapped[str] = mapped_column(String(255))
+
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
+    created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
+
+    files: Mapped[list["LibraryFile"]] = relationship(
+        back_populates="variant_group",
+        order_by="LibraryFile.variant_position",
+    )
+    created_by: Mapped["User | None"] = relationship()
+
+
 class LibraryFile(Base):
     """File stored in the library."""
 
@@ -98,6 +133,23 @@ class LibraryFile(Base):
     source_type: Mapped[str | None] = mapped_column(String(32), nullable=True)
     source_url: Mapped[str | None] = mapped_column(String(512), nullable=True, index=True)
 
+    # Variant grouping (#671 / #2570). A file belongs to at most one group of
+    # "same job, sliced for a different printer" siblings. SET NULL on group
+    # delete: ungrouping must never take the files with it. ``variant_position``
+    # is the user's priority order within the group — when two printers are idle
+    # at the same scheduler tick, the lowest position wins, so the pick is
+    # reproducible instead of depending on which match the scheduler found first.
+    variant_group_id: Mapped[int | None] = mapped_column(
+        ForeignKey("file_variant_groups.id", ondelete="SET NULL"), nullable=True, index=True
+    )
+    variant_position: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
+    # User's answer to "which printer is this for", for a file that does not say.
+    # Files imported before Bambuddy parsed ``sliced_for_model`` — and raw .gcode —
+    # declare nothing, and without this they could never be grouped. Deliberately
+    # NOT written into ``file_metadata``: that holds what was parsed out of the
+    # file, and a user's assertion must not become indistinguishable from it.
+    variant_target_model: Mapped[str | None] = mapped_column(String(50), nullable=True)
+
     # User tracking (Issue #206)
     created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
 
@@ -122,6 +174,7 @@ class LibraryFile(Base):
     folder: Mapped["LibraryFolder | None"] = relationship(back_populates="files")
     project: Mapped["Project | None"] = relationship()
     created_by: Mapped["User | None"] = relationship()
+    variant_group: Mapped["FileVariantGroup | None"] = relationship(back_populates="files")
     # Tags (#1268). M2M via library_file_tags. Loaded explicitly via
     # ``selectinload`` in list_files so each row in the listing carries its
     # chip set without N+1 fetches.

+ 7 - 0
backend/app/models/notification.py

@@ -66,6 +66,7 @@ class NotificationProvider(Base):
     on_print_stopped = Column(Boolean, default=True)  # User cancelled/stopped print
     on_print_progress = Column(Boolean, default=False)  # 25%, 50%, 75% milestones
     on_print_missing_spool_assignment = Column(Boolean, default=False)  # Print started with unassigned required tray(s)
+    on_billing_charge_failed = Column(Boolean, default=True)  # A completed/stopped print could not be charged
 
     # Event triggers - printer status
     on_printer_offline = Column(Boolean, default=False)
@@ -77,11 +78,17 @@ class NotificationProvider(Base):
     # Event triggers - AMS environmental alarms (regular AMS with 4 slots)
     on_ams_humidity_high = Column(Boolean, default=False)  # AMS humidity above threshold
     on_ams_temperature_high = Column(Boolean, default=False)  # AMS temperature above threshold
+    # Auto-drying gave up on a unit (#2770). Defaults True: it reports that
+    # Bambuddy has stopped acting, which nothing else in the UI would say.
+    on_ams_drying_suspended = Column(Boolean, default=True)
 
     # Event triggers - AMS-HT environmental alarms (single slot heated AMS)
     on_ams_ht_humidity_high = Column(Boolean, default=False)  # AMS-HT humidity above threshold
     on_ams_ht_temperature_high = Column(Boolean, default=False)  # AMS-HT temperature above threshold
 
+    # Event triggers - Home Assistant sensors bound to a printer (#1148)
+    on_ha_sensor_alert = Column(Boolean, default=False)  # Bound HA sensor entered its alert state
+
     # Event triggers - Build plate detection
     on_plate_not_empty = Column(Boolean, default=True)  # Objects detected on plate before print
     # Off by default: fires after every print, alongside the print-complete alert (#2525)

+ 22 - 0
backend/app/models/notification_template.py

@@ -61,6 +61,12 @@ DEFAULT_TEMPLATES = [
         "title_template": "Missing Spool Assignment",
         "body_template": "{printer}: print started with missing spool assignments\nSlots: {missing_slots}\nExpected profile:\n{missing_slot_details}",
     },
+    {
+        "event_type": "billing_charge_failed",
+        "name": "Billing Charge Failed",
+        "title_template": "Billing Charge Failed",
+        "body_template": "{printer}: {filename}\nThe print charge could not be recorded. The budget reservation was retained.\nArchive: {archive_id}",
+    },
     {
         "event_type": "printer_offline",
         "name": "Printer Offline",
@@ -115,12 +121,28 @@ DEFAULT_TEMPLATES = [
         "title_template": "AMS Temperature Alert",
         "body_template": "{printer} {ams_label}: Temperature {temperature}°C exceeds {threshold}°C threshold",
     },
+    {
+        "event_type": "ams_drying_suspended",
+        "name": "Auto-Drying Suspended",
+        "title_template": "Auto-Drying Suspended",
+        "body_template": (
+            "{printer} {ams_label}: stopped automatic drying after {cycles} cycles left humidity at "
+            "{humidity}%, still above the {threshold}% threshold. An AMS reads higher while it is warm, "
+            "so raise the threshold or dry the spools off the printer."
+        ),
+    },
     {
         "event_type": "bed_cooled",
         "name": "Bed Cooled",
         "title_template": "Bed Cooled",
         "body_template": "{printer}: Bed cooled to {bed_temp}°C (threshold: {threshold}°C)",
     },
+    {
+        "event_type": "ha_sensor_alert",
+        "name": "Home Assistant Sensor Alert",
+        "title_template": "Sensor Alert",
+        "body_template": "{printer}: {sensor} is {state}",
+    },
     {
         "event_type": "first_layer_complete",
         "name": "First Layer Complete",

+ 58 - 2
backend/app/models/print_batch.py

@@ -1,13 +1,24 @@
 from datetime import datetime
 
-from sqlalchemy import DateTime, ForeignKey, Integer, String, func
+from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from backend.app.core.database import Base
 
 
 class PrintBatch(Base):
-    """Batch grouping for multiple queue items created from the same file."""
+    """Batch grouping for multiple queue items created from the same file.
+
+    A batch carries the *intent* — how many of each plate are wanted — in its
+    :class:`PrintBatchPlate` rows, while the queue items it spawned carry what
+    was actually dispatched. Keeping the two apart is what lets a failed print
+    still count as owed work: the plate row's ``quantity_target`` stays put
+    while the failed item lands in the "failed" bucket, so ``remaining`` goes
+    back up instead of the order silently under-delivering (#342).
+
+    Batches created before plate rows existed simply have none; every consumer
+    falls back to deriving progress from the queue items alone.
+    """
 
     __tablename__ = "print_batches"
 
@@ -26,8 +37,17 @@ class PrintBatch(Base):
     # Status: active, completed, cancelled
     status: Mapped[str] = mapped_column(String(20), default="active")
 
+    # Optional link to a Project, which owns the heavier planning metadata
+    # (BOM, attachments, tags). The batch keeps only the two fields that are
+    # useless without it — a date and free text — so an order doesn't force
+    # the user to create a Project first.
+    project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
+    due_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    notes: Mapped[str | None] = mapped_column(Text, nullable=True)
+
     # Timestamps
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
 
     # User tracking
     created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
@@ -37,6 +57,42 @@ class PrintBatch(Base):
     library_file: Mapped["LibraryFile | None"] = relationship()
     created_by: Mapped["User | None"] = relationship()
     queue_items: Mapped[list["PrintQueueItem"]] = relationship(back_populates="batch")
+    plates: Mapped[list["PrintBatchPlate"]] = relationship(
+        back_populates="batch",
+        cascade="all, delete-orphan",
+        order_by="PrintBatchPlate.sort_order",
+    )
+
+
+class PrintBatchPlate(Base):
+    """How many runs of one plate a batch still owes.
+
+    ``plate_id`` is the plate index within the source 3MF, or NULL for a
+    single-plate file / whole-file print — the same convention
+    ``PrintQueueItem.plate_id`` uses, so progress can be derived by grouping
+    the batch's items on that column.
+    """
+
+    __tablename__ = "print_batch_plates"
+    __table_args__ = (UniqueConstraint("batch_id", "plate_id", name="uq_batch_plate"),)
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    batch_id: Mapped[int] = mapped_column(
+        ForeignKey("print_batches.id", ondelete="CASCADE"), nullable=False, index=True
+    )
+
+    plate_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
+    plate_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
+
+    # How many runs of this plate the order wants. Zero is legal — a plate the
+    # user explicitly marked "not required" keeps its row so it can be raised
+    # later without re-creating the order.
+    quantity_target: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
+
+    # Display order; mirrors the plate order in the source file.
+    sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
+
+    batch: Mapped["PrintBatch"] = relationship(back_populates="plates")
 
 
 from backend.app.models.archive import PrintArchive  # noqa: E402

+ 7 - 0
backend/app/models/print_log.py

@@ -24,6 +24,13 @@ class PrintLogEntry(Base):
     archive_id: Mapped[int | None] = mapped_column(
         ForeignKey("print_archives.id", ondelete="SET NULL"), nullable=True, index=True
     )
+    # Which queue item produced this run, when one did. Printer-initiated
+    # prints have none. Batch cost/energy roll-up joins on this (#342): the
+    # archive alone can't attribute a run to an order because several orders
+    # — and plain reprints — share one archive.
+    queue_item_id: Mapped[int | None] = mapped_column(
+        ForeignKey("print_queue.id", ondelete="SET NULL"), nullable=True, index=True
+    )
     print_name: Mapped[str | None] = mapped_column(String(255))
     printer_name: Mapped[str | None] = mapped_column(String(255))
     printer_id: Mapped[int | None] = mapped_column(Integer)

+ 98 - 1
backend/app/models/print_queue.py

@@ -1,6 +1,6 @@
 from datetime import datetime
 
-from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from backend.app.core.database import Base
@@ -32,6 +32,13 @@ class PrintQueueItem(Base):
     library_file_id: Mapped[int | None] = mapped_column(
         ForeignKey("library_files.id", ondelete="CASCADE"), nullable=True
     )
+    cost_center_id: Mapped[int | None] = mapped_column(
+        ForeignKey("cost_centers.id", ondelete="SET NULL"), nullable=True
+    )
+    estimated_cost: Mapped[float | None] = mapped_column(Float, nullable=True)
+    # Bambuddy-owned globally unique identity for one physical dispatch. This
+    # must not reuse the printer protocol's 31-bit subtask_id.
+    billing_run_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
     project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
     batch_id: Mapped[int | None] = mapped_column(ForeignKey("print_batches.id", ondelete="SET NULL"), nullable=True)
 
@@ -85,6 +92,17 @@ class PrintQueueItem(Base):
     nozzle_mapping: Mapped[str | None] = mapped_column(Text, nullable=True)
     nozzles_info: Mapped[str | None] = mapped_column(Text, nullable=True)
 
+    # Which rack position each filament group prints from, on a nozzle-rack
+    # machine (#1784). JSON object keyed by the 3MF's group id, valued with a
+    # 1-based rack position as the operator counts them.
+    #
+    # Deliberately not the expanded `nozzle_mapping` above, though that is what
+    # goes on the wire: the rack can be re-loaded between queueing and
+    # dispatch, and only the position-and-group form can be re-checked against
+    # what is actually mounted at the moment the job runs. NULL means nothing
+    # was picked, and the dispatcher assigns positions itself.
+    nozzle_rack_choice: Mapped[str | None] = mapped_column(Text, nullable=True)
+
     # Printer-card direct uploads create transient library rows. When this is
     # true, the scheduler deletes the source row/files after archiving a copy.
     cleanup_library_after_dispatch: Mapped[bool] = mapped_column(Boolean, default=False)
@@ -162,12 +180,91 @@ class PrintQueueItem(Base):
     printer: Mapped["Printer"] = relationship()
     archive: Mapped["PrintArchive | None"] = relationship()
     library_file: Mapped["LibraryFile | None"] = relationship()
+    cost_center: Mapped["CostCenter | None"] = relationship()
     project: Mapped["Project | None"] = relationship(back_populates="queue_items")
     batch: Mapped["PrintBatch | None"] = relationship(back_populates="queue_items")
     created_by: Mapped["User | None"] = relationship()
+    variants: Mapped[list["PrintQueueVariant"]] = relationship(
+        back_populates="queue_item",
+        cascade="all, delete-orphan",
+        order_by="PrintQueueVariant.position",
+    )
+
+
+class PrintQueueVariant(Base):
+    """One candidate file for a queue item that may print on several models (#671).
+
+    A user with an H2S and an H2C slices the same job twice and does not care
+    which machine runs it. Each slice becomes a variant; the scheduler walks them
+    in ``position`` order and takes the first whose model has an idle printer.
+
+    **This is a snapshot, not a pointer.** The candidate list is copied from the
+    library's variant group when the item is queued, and every per-file setting
+    the dispatcher needs is copied with it. Two reasons:
+
+    - Editing the library group afterwards must not silently change a job that is
+      already waiting in the queue.
+    - The per-file settings genuinely differ between candidates and are choices
+      the user made for *this* job, not properties of the file. An H2C slice is
+      dual-nozzle and will not have the same slot count, AMS mapping or nozzle
+      mapping as the H2S slice of the same model.
+
+    On a match the winning variant's fields are written onto the queue row before
+    the dispatch commit, so everything downstream — upload, archive creation,
+    print history, reprint — sees an ordinary single-file item and needs no
+    knowledge that variants exist.
+
+    Variants reference library files only. An archive records a print that already
+    happened, of one specific file, so it is never a candidate for "which of these
+    should we run".
+    """
+
+    __tablename__ = "print_queue_variants"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    queue_item_id: Mapped[int] = mapped_column(
+        ForeignKey("print_queue.id", ondelete="CASCADE"), nullable=False, index=True
+    )
+    # User's priority order. When two printers are idle in the same scheduler
+    # pass, the lowest position wins — so the choice is reproducible instead of
+    # depending on which match the matcher happened to find first.
+    position: Mapped[int] = mapped_column(Integer, default=0)
+
+    # CASCADE: deleting the file drops this candidate but leaves the item and its
+    # other candidates alone. Losing the *last* candidate is handled by the
+    # resolver, which holds the item pending with an explicit waiting_reason
+    # rather than letting it sit there looking dispatchable forever.
+    library_file_id: Mapped[int] = mapped_column(ForeignKey("library_files.id", ondelete="CASCADE"), nullable=False)
+    # Normalized short name ("H2S"), taken from the file's own sliced_for_model
+    # at creation, or picked by the user for a legacy file that declares none.
+    target_model: Mapped[str] = mapped_column(String(50), nullable=False)
+
+    # Per-file dispatch settings, same semantics as the identically named columns
+    # on PrintQueueItem — see there for the formats.
+    plate_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
+    ams_mapping: Mapped[str | None] = mapped_column(Text, nullable=True)
+    nozzle_mapping: Mapped[str | None] = mapped_column(Text, nullable=True)
+    nozzle_rack_choice: Mapped[str | None] = mapped_column(Text, nullable=True)
+    filament_overrides: Mapped[str | None] = mapped_column(Text, nullable=True)
+    required_filament_types: Mapped[str | None] = mapped_column(Text, nullable=True)
+    print_time_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)
+
+    # How many times this candidate has been dispatched and bounced back to
+    # pending by the start-watchdog. The resolver tries least-attempted first, so
+    # a printer that accepts the file and never starts (#1678) hands the job to
+    # the other machine on the next lap instead of burning the item's whole
+    # DISPATCH_MAX_ATTEMPTS budget against the same wedged printer — which is the
+    # entire reason the user queued an alternative.
+    attempt_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
+
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+
+    queue_item: Mapped["PrintQueueItem"] = relationship(back_populates="variants")
+    library_file: Mapped["LibraryFile"] = relationship()
 
 
 from backend.app.models.archive import PrintArchive  # noqa: E402
+from backend.app.models.finance import CostCenter  # noqa: E402
 from backend.app.models.library import LibraryFile  # noqa: E402
 from backend.app.models.print_batch import PrintBatch  # noqa: E402
 from backend.app.models.printer import Printer  # noqa: E402

+ 2 - 0
backend/app/models/printer.py

@@ -61,6 +61,7 @@ class Printer(Base):
     sensor_history: Mapped[list["PrinterSensorHistory"]] = relationship(
         back_populates="printer", cascade="all, delete-orphan"
     )
+    ha_sensors: Mapped[list["PrinterHASensor"]] = relationship(back_populates="printer", cascade="all, delete-orphan")
 
 
 from backend.app.models.ams_history import AMSSensorHistory  # noqa: E402
@@ -68,5 +69,6 @@ from backend.app.models.archive import PrintArchive  # noqa: E402
 from backend.app.models.kprofile_note import KProfileNote  # noqa: E402
 from backend.app.models.maintenance import PrinterMaintenance  # noqa: E402
 from backend.app.models.notification import NotificationProvider  # noqa: E402
+from backend.app.models.printer_ha_sensor import PrinterHASensor  # noqa: E402
 from backend.app.models.printer_sensor_history import PrinterSensorHistory  # noqa: E402
 from backend.app.models.smart_plug import SmartPlug  # noqa: E402

+ 72 - 0
backend/app/models/printer_ha_sensor.py

@@ -0,0 +1,72 @@
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from backend.app.core.database import Base
+
+
+class PrinterHASensor(Base):
+    """A read-only Home Assistant entity bound to a printer (#1148, #448).
+
+    Deliberately *not* a ``SmartPlug`` row with a wider entity pattern. A plug
+    carries auto-on/auto-off, schedules, power alerts, energy snapshots and
+    ``controls_printer_power``; none of that means anything for a door contact,
+    and ``get_smart_plug_by_printer`` would hand the card's power button a
+    sensor to switch. Sensors get their own table and their own read-only
+    routes instead.
+
+    Not to be confused with ``PrinterSensorHistory``, which stores the
+    printer's *own* heater readings.
+    """
+
+    __tablename__ = "printer_ha_sensors"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"), index=True)
+
+    name: Mapped[str] = mapped_column(String(100))
+    entity_id: Mapped[str] = mapped_column(String(255))
+
+    # "binary" for binary_sensor.*, "numeric" for sensor.*. Decides how the
+    # state is rendered and which alert fields apply.
+    kind: Mapped[str] = mapped_column(String(16), default="binary")
+
+    # HA's own device_class, snapshotted when the entity is bound. Drives the
+    # on/off wording (door -> Open/Closed, motion -> Detected/Clear) and the
+    # icon, so the card doesn't have to say "On" for an open door.
+    device_class: Mapped[str | None] = mapped_column(String(32), nullable=True)
+    # Numeric only: "°C", "%", "ppm", ... shown next to the value.
+    unit: Mapped[str | None] = mapped_column(String(16), nullable=True)
+
+    # What counts as needing attention. One notion, three consumers: the pill
+    # colour on the card, the notification, and the print interlock.
+    # Binary sensors use alert_state ("on"/"off"/None), numeric ones the
+    # thresholds. All None means "just show the value".
+    alert_state: Mapped[str | None] = mapped_column(String(8), nullable=True)
+    alert_above: Mapped[float | None] = mapped_column(Float, nullable=True)
+    alert_below: Mapped[float | None] = mapped_column(Float, nullable=True)
+
+    # Hold queued prints for this printer while the sensor is in its alert
+    # state — the enclosure-door case this feature was asked for. Opt-in, and
+    # only ever a *hold*: the item stays pending with a waiting_reason and
+    # dispatches by itself once the door closes.
+    block_print: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
+    notify_on_alert: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
+
+    show_on_printer_card: Mapped[bool] = mapped_column(Boolean, default=True, server_default="1")
+    sort_order: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
+
+    # Last poll result. Persisted so a restart doesn't blank the card until the
+    # first poll lands, and so notifications only fire on a real transition.
+    last_state: Mapped[str | None] = mapped_column(String(64), nullable=True)
+    last_changed: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    last_checked: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
+
+    printer: Mapped["Printer"] = relationship(back_populates="ha_sensors")
+
+
+from backend.app.models.printer import Printer  # noqa: E402

+ 10 - 2
backend/app/schemas/archive.py

@@ -1,10 +1,18 @@
 from datetime import datetime
+from typing import Annotated
 
-from pydantic import BaseModel, model_validator
+from pydantic import BaseModel, BeforeValidator, model_validator
+
+from backend.app.utils.filename import clean_display_name
+
+# Free text, punctuation and all -- only control characters are taken out, and
+# only on the way in (#2832). Anything that turns a name into a path sanitises
+# it there instead, where the budget and the fallback are known.
+DisplayName = Annotated[str | None, BeforeValidator(clean_display_name)]
 
 
 class ArchiveBase(BaseModel):
-    print_name: str | None = None
+    print_name: DisplayName = None
     is_favorite: bool | None = None
     tags: str | None = None
     notes: str | None = None

+ 16 - 0
backend/app/schemas/auth.py

@@ -93,6 +93,22 @@ class UserResponse(BaseModel):
         from_attributes = True
 
 
+class UserSlim(BaseModel):
+    """Just enough to resolve a user id to a display name (#1894).
+
+    Deliberately narrower than ``UserResponse``: no email, role, auth source,
+    group membership or permission set. Adding a field here widens what every
+    ``can_read_status`` API key can read about every account, so treat this
+    shape as the contract rather than a starting point.
+    """
+
+    id: int
+    username: str
+
+    class Config:
+        from_attributes = True
+
+
 class LDAPSearchResultResponse(BaseModel):
     """One match from GET /auth/ldap/search — surfaced in the admin UI."""
 

+ 6 - 0
backend/app/schemas/cloud.py

@@ -30,6 +30,12 @@ class CloudLoginResponse(BaseModel):
     message: str
     verification_type: str | None = None  # "email" or "totp"
     tfa_key: str | None = None  # Key needed for TOTP verification
+    # Machine-readable cause of a failure, when we know it. Currently only
+    # "captcha" — Bambu's anti-abuse layer is challenging this network and no
+    # credential will be accepted until it clears (#2790). The UI needs this to
+    # explain the situation in place, rather than flashing ``message`` as a
+    # toast that vanishes and leaves the user retrying a password that is fine.
+    reason: str | None = None
 
 
 class CloudAuthStatus(BaseModel):

+ 118 - 0
backend/app/schemas/finance.py

@@ -0,0 +1,118 @@
+from datetime import datetime
+from typing import Literal
+
+from pydantic import BaseModel, Field
+
+
+class WalletBalanceResponse(BaseModel):
+    user_id: int
+    balance: float
+    currency: str
+    updated_at: datetime | None = None
+
+
+class WalletTransactionResponse(BaseModel):
+    id: int
+    user_id: int
+    cost_center_id: int | None = None
+    transaction_type: Literal["print_charge", "deposit", "withdraw", "manual_adjustment"]
+    amount: float
+    balance_after: float | None = None
+    description: str | None = None
+    created_by_user_id: int | None = None
+    print_run_id: str | None = None
+    print_archive_id: int | None = None
+    print_queue_id: int | None = None
+    created_at: datetime
+
+    class Config:
+        from_attributes = True
+
+
+class WalletTransactionListResponse(BaseModel):
+    items: list[WalletTransactionResponse]
+    total: int
+    limit: int
+    offset: int
+
+
+class CostCenterSummaryResponse(BaseModel):
+    id: int
+    name: str
+    is_private: bool
+    owner_user_id: int | None = None
+    is_active: bool
+    total_balance: float = 0.0
+    total_budget: float | None = None
+    monthly_budget: float | None = None
+    budget_mode: str = "none"
+    budget_limit: float | None = None
+    budget_used: float | None = None
+    budget_available: float | None = None
+    can_print: bool = True
+
+    class Config:
+        from_attributes = True
+
+
+class WalletAdjustmentRequest(BaseModel):
+    amount: float = Field(..., gt=0)
+    description: str | None = None
+    cost_center_id: int | None = None
+
+
+class WalletAdjustmentResponse(BaseModel):
+    transaction: WalletTransactionResponse
+    balance: WalletBalanceResponse
+
+
+class TransactionEditRequest(BaseModel):
+    user_id: int | None = None
+    cost_center_id: int | None = None
+    amount: float | None = None
+    description: str | None = None
+
+
+class ManualPrintRequest(BaseModel):
+    user_id: int
+    cost_center_id: int
+    amount: float = Field(..., gt=0)
+    description: str | None = None
+    created_at: datetime | None = None
+
+
+class CostCenterCreateRequest(BaseModel):
+    name: str = Field(..., min_length=1, max_length=150)
+    total_budget: float | None = Field(default=None, ge=0)
+    monthly_budget: float | None = Field(default=None, ge=0)
+    is_active: bool = True
+
+
+class CostCenterUpdateRequest(BaseModel):
+    name: str | None = Field(default=None, min_length=1, max_length=150)
+    is_active: bool | None = None
+
+
+class CostCenterBudgetUpdateRequest(BaseModel):
+    total_budget: float | None = Field(default=None, ge=0)
+    monthly_budget: float | None = Field(default=None, ge=0)
+
+
+class CostCenterMemberRequest(BaseModel):
+    user_id: int
+    can_print: bool = True
+
+
+class CostCenterMemberResponse(BaseModel):
+    id: int
+    cost_center_id: int
+    user_id: int
+    can_print: bool
+    created_at: datetime
+
+    class Config:
+        from_attributes = True
+
+
+class CostCenterDetailResponse(CostCenterSummaryResponse):
+    members: list[CostCenterMemberResponse] = []

+ 123 - 0
backend/app/schemas/github_backup.py

@@ -176,6 +176,7 @@ class GitHubBackupStatus(BaseModel):
     configured: bool = Field(description="Whether backup is configured")
     enabled: bool = Field(description="Whether backup is enabled")
     is_running: bool = Field(description="Whether a backup is currently running")
+    restore_running: bool = Field(default=False, description="Whether a restore is currently running")
     progress: str | None = Field(default=None, description="Current backup progress message")
     last_backup_at: datetime | None
     last_backup_status: str | None
@@ -204,3 +205,125 @@ class GitHubBackupTriggerResponse(BaseModel):
     log_id: int | None = None
     commit_sha: str | None = None
     files_changed: int = 0
+
+
+# --- Restore (issue #2656) --------------------------------------------------
+
+# "HEAD" means "whatever the branch tip is right now"; the service resolves it
+# to a concrete SHA before reading anything so preview and apply can't straddle
+# two different commits. Anything else must look like a git object name.
+REF_PATTERN = r"^(?:HEAD|[0-9a-fA-F]{7,40})$"
+
+
+class RestoreCategory(StrEnum):
+    """Backup categories that can be restored.
+
+    Cloud profiles are deliberately absent: 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. Tracked separately from #2656.
+    """
+
+    KPROFILES = "kprofiles"
+    SETTINGS = "settings"
+    SPOOLS = "spools"
+    ARCHIVES = "archives"
+
+
+class GitHubCommitInfo(BaseModel):
+    """One commit in the backup repository."""
+
+    sha: str
+    message: str
+    author: str
+    date: str
+
+
+class GitHubCommitListResponse(BaseModel):
+    """Schema for the commit picker."""
+
+    success: bool
+    message: str
+    branch: str
+    commits: list[GitHubCommitInfo] = Field(default_factory=list)
+
+
+class GitHubRestorePreviewCategory(BaseModel):
+    """What a single category looks like inside one backup commit."""
+
+    category: RestoreCategory
+    available: bool = Field(description="Whether this category is present in the commit")
+    item_count: int = Field(default=0, description="Rows/profiles found, 0 when unavailable")
+    detail: str | None = Field(default=None, description="Why unavailable, or extra context, in English")
+    detail_code: str | None = Field(
+        default=None, description="Key under backup.restoreFromGit.details, for the client to translate"
+    )
+    detail_params: dict[str, str | int] = Field(
+        default_factory=dict, description="Interpolation values for detail_code"
+    )
+
+
+class GitHubRestorePreview(BaseModel):
+    """Schema for inspecting a commit before restoring from it."""
+
+    success: bool
+    message: str
+    ref: str = Field(description="The concrete commit SHA that was inspected")
+    commit: GitHubCommitInfo | None = None
+    metadata_version: str | None = Field(default=None, description="version field from backup_metadata.json")
+    categories: list[GitHubRestorePreviewCategory] = Field(default_factory=list)
+
+
+class GitHubRestoreRequest(BaseModel):
+    """Schema for triggering a restore."""
+
+    ref: str = Field(default="HEAD", pattern=REF_PATTERN, description="Commit SHA to restore from, or HEAD")
+    categories: list[RestoreCategory] = Field(..., min_length=1, description="Categories to restore")
+    overwrite_existing: bool = Field(
+        default=False,
+        description="Update rows that already exist locally. When false, only missing rows are inserted.",
+    )
+
+    @model_validator(mode="after")
+    def deduplicate_categories(self) -> "GitHubRestoreRequest":
+        # Same category twice would double-count the result totals.
+        seen: list[RestoreCategory] = []
+        for category in self.categories:
+            if category not in seen:
+                seen.append(category)
+        self.categories = seen
+        return self
+
+
+class GitHubRestoreNote(BaseModel):
+    """One tally note, as a translation code plus the values it interpolates.
+
+    Follows the ``backup.pathCheck`` contract already in use one card down in the
+    same component: the server chooses the code and supplies typed params, and
+    the client renders ``t(`...${code}`, { ...params, defaultValue: message })``.
+    ``message`` is the English original, so a client that does not know a code
+    yet still shows something sensible rather than the raw key.
+    """
+
+    code: str = Field(description="Key under backup.restoreFromGit.notes")
+    params: dict[str, str | int] = Field(default_factory=dict, description="Interpolation values for code")
+    message: str = Field(description="English rendering, used as the client's defaultValue")
+
+
+class GitHubRestoreCategoryResult(BaseModel):
+    """Per-category outcome of a restore."""
+
+    restored: int = 0
+    skipped: int = 0
+    failed: int = 0
+    notes: list[GitHubRestoreNote] = Field(default_factory=list)
+
+
+class GitHubRestoreResponse(BaseModel):
+    """Schema for the restore result."""
+
+    success: bool
+    message: str
+    log_id: int | None = None
+    ref: str | None = Field(default=None, description="The concrete commit SHA restored from")
+    results: dict[str, GitHubRestoreCategoryResult] = Field(default_factory=dict)

+ 62 - 0
backend/app/schemas/library.py

@@ -220,6 +220,13 @@ class FileListResponse(BaseModel):
     # never null, so the FE can iterate without a guard.
     tags: list[TagSummary] = []
 
+    # Variant grouping (#671 / #2570). ``variant_count`` is the size of the whole
+    # group, not of the current listing — members can live in different folders,
+    # so counting the rows on screen would under-report. Projected in the list
+    # query so the badge and the smart-print decision cost no extra request.
+    variant_group_id: int | None = None
+    variant_count: int = 0
+
     class Config:
         from_attributes = True
 
@@ -397,3 +404,58 @@ class BatchThumbnailResponse(BaseModel):
     succeeded: int
     failed: int
     results: list[BatchThumbnailResult]
+
+
+# ============ Variant Group Schemas (#671 / #2570) ============
+
+
+class VariantGroupMemberRequest(BaseModel):
+    """One file joining a variant group.
+
+    ``target_model`` is optional and normally omitted — it is read from the
+    file's own ``sliced_for_model``. Supply it only for a legacy 3MF that
+    declares no model, where there is nothing else to go on.
+    """
+
+    library_file_id: int
+    target_model: str | None = Field(None, max_length=50)
+
+
+class VariantGroupCreate(BaseModel):
+    """Declare that these files are the same job sliced for different printers.
+
+    Order is significant: it is the priority used when more than one printer is
+    idle at the same moment. Two members minimum — a group of one expresses no
+    choice.
+    """
+
+    members: list[VariantGroupMemberRequest] = Field(..., min_length=2)
+    name: str | None = Field(None, max_length=255)
+
+
+class VariantGroupUpdate(BaseModel):
+    """Rename a group and/or re-order its members.
+
+    ``member_file_ids`` must list exactly the group's current members; a partial
+    list is rejected rather than guessing where the omitted ones belong.
+    """
+
+    name: str | None = Field(None, max_length=255)
+    member_file_ids: list[int] | None = None
+
+
+class VariantGroupMemberResponse(BaseModel):
+    """A file within a group, with the model it will be dispatched to."""
+
+    library_file_id: int
+    filename: str
+    target_model: str
+    position: int
+
+
+class VariantGroupResponse(BaseModel):
+    """A variant group and its members, in priority order."""
+
+    id: int
+    name: str
+    members: list[VariantGroupMemberResponse]

+ 14 - 0
backend/app/schemas/notification.py

@@ -40,6 +40,7 @@ class NotificationProviderBase(BaseModel):
         default=False,
         description="Notify when a print starts with required trays missing spool assignments",
     )
+    on_billing_charge_failed: bool = Field(default=True, description="Notify when a print charge cannot be recorded")
 
     # Event triggers - printer status
     on_printer_offline: bool = Field(default=False, description="Notify when printer goes offline")
@@ -54,6 +55,9 @@ class NotificationProviderBase(BaseModel):
     # Event triggers - AMS environmental alarms (regular AMS)
     on_ams_humidity_high: bool = Field(default=False, description="Notify when AMS humidity exceeds threshold")
     on_ams_temperature_high: bool = Field(default=False, description="Notify when AMS temperature exceeds threshold")
+    on_ams_drying_suspended: bool = Field(
+        default=True, description="Notify when automatic drying gives up on an AMS unit"
+    )
 
     # Event triggers - AMS-HT environmental alarms
     on_ams_ht_humidity_high: bool = Field(default=False, description="Notify when AMS-HT humidity exceeds threshold")
@@ -61,6 +65,11 @@ class NotificationProviderBase(BaseModel):
         default=False, description="Notify when AMS-HT temperature exceeds threshold"
     )
 
+    # Event triggers - Home Assistant sensors (#1148)
+    on_ha_sensor_alert: bool = Field(
+        default=False, description="Notify when a bound Home Assistant sensor enters its alert state"
+    )
+
     # Event triggers - Build plate detection
     on_plate_not_empty: bool = Field(default=True, description="Notify when objects detected on plate before print")
     on_plate_clear_required: bool = Field(
@@ -132,6 +141,7 @@ class NotificationProviderUpdate(BaseModel):
     on_print_stopped: bool | None = None
     on_print_progress: bool | None = None
     on_print_missing_spool_assignment: bool | None = None
+    on_billing_charge_failed: bool | None = None
 
     # Event triggers - printer status
     on_printer_offline: bool | None = None
@@ -143,11 +153,15 @@ class NotificationProviderUpdate(BaseModel):
     # Event triggers - AMS environmental alarms (regular AMS)
     on_ams_humidity_high: bool | None = None
     on_ams_temperature_high: bool | None = None
+    on_ams_drying_suspended: bool | None = None
 
     # Event triggers - AMS-HT environmental alarms
     on_ams_ht_humidity_high: bool | None = None
     on_ams_ht_temperature_high: bool | None = None
 
+    # Event triggers - Home Assistant sensors (#1148)
+    on_ha_sensor_alert: bool | None = None
+
     # Event triggers - Build plate detection
     on_plate_not_empty: bool | None = None
     on_plate_clear_required: bool | None = None

+ 38 - 0
backend/app/schemas/notification_template.py

@@ -16,13 +16,16 @@ class EventType(StrEnum):
     PRINT_STOPPED = "print_stopped"
     PRINT_PROGRESS = "print_progress"
     PRINT_MISSING_SPOOL_ASSIGNMENT = "print_missing_spool_assignment"
+    BILLING_CHARGE_FAILED = "billing_charge_failed"
     PRINTER_OFFLINE = "printer_offline"
     PRINTER_ERROR = "printer_error"
     FILAMENT_LOW = "filament_low"
     MAINTENANCE_DUE = "maintenance_due"
     AMS_HUMIDITY_HIGH = "ams_humidity_high"
     AMS_TEMPERATURE_HIGH = "ams_temperature_high"
+    AMS_DRYING_SUSPENDED = "ams_drying_suspended"
     BED_COOLED = "bed_cooled"
+    HA_SENSOR_ALERT = "ha_sensor_alert"
     TEST = "test"
 
 
@@ -70,13 +73,24 @@ EVENT_VARIABLES: dict[str, list[str]] = {
         "timestamp",
         "app_name",
     ],
+    "billing_charge_failed": ["printer", "filename", "archive_id", "error", "timestamp", "app_name"],
     "printer_offline": ["printer", "timestamp", "app_name"],
     "printer_error": ["printer", "error_type", "error_detail", "timestamp", "app_name"],
     "filament_low": ["printer", "slot", "remaining_percent", "color", "timestamp", "app_name"],
     "maintenance_due": ["printer", "items", "timestamp", "app_name"],
     "ams_humidity_high": ["printer", "ams_label", "humidity", "threshold", "timestamp", "app_name"],
     "ams_temperature_high": ["printer", "ams_label", "temperature", "threshold", "timestamp", "app_name"],
+    "ams_drying_suspended": [
+        "printer",
+        "ams_label",
+        "humidity",
+        "threshold",
+        "cycles",
+        "timestamp",
+        "app_name",
+    ],
     "bed_cooled": ["printer", "bed_temp", "threshold", "filename", "timestamp", "app_name"],
+    "ha_sensor_alert": ["printer", "sensor", "state", "timestamp", "app_name"],
     "test": ["app_name", "timestamp"],
     # Queue notifications
     "queue_job_added": ["job_name", "target", "timestamp", "app_name"],
@@ -155,6 +169,14 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "timestamp": "2024-01-15 14:30",
         "app_name": "Bambuddy",
     },
+    "billing_charge_failed": {
+        "printer": "Bambu X1C",
+        "filename": "Benchy.3mf",
+        "archive_id": "123",
+        "error": "The transaction could not be persisted",
+        "timestamp": "2024-01-15 15:48",
+        "app_name": "Bambuddy",
+    },
     "printer_offline": {
         "printer": "Bambu X1C",
         "timestamp": "2024-01-15 14:30",
@@ -197,6 +219,15 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "timestamp": "2024-01-15 14:30",
         "app_name": "Bambuddy",
     },
+    "ams_drying_suspended": {
+        "printer": "Bambu X1C",
+        "ams_label": "AMS-A",
+        "humidity": "16",
+        "threshold": "14",
+        "cycles": "2",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "Bambuddy",
+    },
     "bed_cooled": {
         "printer": "Bambu X1C",
         "bed_temp": "34",
@@ -205,6 +236,13 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "timestamp": "2024-01-15 14:30",
         "app_name": "Bambuddy",
     },
+    "ha_sensor_alert": {
+        "printer": "Bambu X1C",
+        "sensor": "Enclosure Door",
+        "state": "open",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "Bambuddy",
+    },
     "test": {
         "app_name": "Bambuddy",
         "timestamp": "2024-01-15 14:30",

+ 9 - 1
backend/app/schemas/print_log.py

@@ -1,9 +1,17 @@
 from datetime import datetime
 
-from pydantic import BaseModel
+from pydantic import BaseModel, ConfigDict
 
 
 class PrintLogEntrySchema(BaseModel):
+    # from_attributes lets the routes build this straight off the ORM row.
+    # The GET serialiser used to name every field by hand, and each field it
+    # forgot came back as its default — a silent null rather than an error.
+    # That cost the log its failure_reason (#1687 part 4) and then its cost /
+    # energy_kwh / energy_cost (#2636). Validating from the row removes the
+    # chance to forget one.
+    model_config = ConfigDict(from_attributes=True)
+
     id: int
     archive_id: int | None = None
     print_name: str | None = None

+ 155 - 3
backend/app/schemas/print_queue.py

@@ -3,6 +3,8 @@ from typing import Annotated, Literal
 
 from pydantic import BaseModel, BeforeValidator, Field, PlainSerializer, model_validator
 
+from backend.app.utils.printer_models import MAX_CHAMBER_TEMP_C
+
 
 # Custom serializer to ensure UTC datetimes have Z suffix
 def serialize_utc_datetime(dt: datetime | None) -> str | None:
@@ -42,6 +44,29 @@ def _coerce_tristate(v: object) -> object:
 TriState = Annotated[Literal["off", "on", "auto"], BeforeValidator(_coerce_tristate)]
 
 
+class QueueVariantCreate(BaseModel):
+    """One candidate file for a cross-model queue item (#671).
+
+    Per-file rather than per-item because the settings genuinely differ between
+    candidates: an H2C slice is dual-nozzle and will not share slot count, AMS
+    mapping or nozzle mapping with the H2S slice of the same model.
+
+    ``target_model`` is normally omitted and read from the file's own
+    ``sliced_for_model``; supply it only for a legacy 3MF that declares none.
+    """
+
+    library_file_id: int
+    target_model: str | None = None
+    plate_id: int | None = None
+    ams_mapping: list[int] | None = None
+    nozzle_mapping: list[int] | None = None
+    # Which rack position each filament group prints from (#1784), as
+    # {group_id: 1-based position}. The operator's pick, re-checked against the
+    # live rack at dispatch; null means "assign them for me".
+    nozzle_rack_choice: dict[int, int] | None = None
+    filament_overrides: list[dict] | None = None
+
+
 class PrintQueueItemCreate(BaseModel):
     printer_id: int | None = None  # None = unassigned, user assigns later
     target_model: str | None = None  # Target printer model (mutually exclusive with printer_id)
@@ -82,7 +107,7 @@ class PrintQueueItemCreate(BaseModel):
     # preheat_enabled setting; 'on' / 'off' force the decision. The chamber
     # target falls through: this override → max(filament-map[loaded tray]) → 0.
     preheat_override: Literal["inherit", "on", "off"] = "inherit"
-    preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=60)
+    preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=MAX_CHAMBER_TEMP_C)
     # Auto-print G-code injection
     gcode_injection: bool = False
     # Batch: create multiple copies (creates a batch if > 1)
@@ -93,9 +118,21 @@ class PrintQueueItemCreate(BaseModel):
     batch_id: int | None = None
     # Project to associate the resulting archive with
     project_id: int | None = None
+    cost_center_id: int | None = None
+    estimated_cost: float | None = None
+    # Which rack position each filament group prints from (#1784), as
+    # {group_id: 1-based position}. The operator's pick, re-checked against the
+    # live rack at dispatch; null means "assign them for me".
+    nozzle_rack_choice: dict[int, int] | None = None
     # Direct printer-card uploads are temporary library files. The scheduler
     # deletes them after creating the durable archive copy.
     cleanup_library_after_dispatch: bool = False
+    # Cross-model alternatives (#671): several sliced files, one job, whichever
+    # printer frees up first. Mutually exclusive with printer_id (a specific
+    # printer defeats the purpose) and with archive_id/library_file_id (the
+    # candidates ARE the files). The scheduler resolves one onto the row at
+    # dispatch, after which the item is an ordinary single-file job.
+    variants: list[QueueVariantCreate] | None = None
 
 
 class PrintQueueItemUpdate(BaseModel):
@@ -119,13 +156,28 @@ class PrintQueueItemUpdate(BaseModel):
     use_ams: bool | None = None
     nozzle_offset_cali: TriState | None = None
     preheat_override: Literal["inherit", "on", "off"] | None = None
-    preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=60)
+    preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=MAX_CHAMBER_TEMP_C)
     # Auto-print G-code injection
     gcode_injection: bool | None = None
+    cost_center_id: int | None = None
+    estimated_cost: float | None = None
     # H2C dual-nozzle-rack slicer pick (#1780). list[int] per-filament
     # physical nozzle position IDs from BambuStudio's project_file MQTT
     # body; sent back to the printer verbatim on dispatch.
     nozzle_mapping: list[int] | None = None
+    # Which rack position each filament group prints from (#1784), as
+    # {group_id: 1-based position}. The operator's pick, re-checked against the
+    # live rack at dispatch; null means "assign them for me".
+    nozzle_rack_choice: dict[int, int] | None = None
+
+
+class QueueVariantSummary(BaseModel):
+    """One candidate on a cross-model queue item, for display (#671)."""
+
+    library_file_id: int
+    filename: str
+    target_model: str
+    position: int
 
 
 class PrintQueueItemResponse(BaseModel):
@@ -138,6 +190,8 @@ class PrintQueueItemResponse(BaseModel):
     waiting_reason: str | None = None  # Why a model-based job hasn't started yet
     archive_id: int | None  # None if library_file_id is set (archive created at print start)
     library_file_id: int | None  # For queue items from library files
+    cost_center_id: int | None = None
+    estimated_cost: float | None = None
     position: int
     scheduled_time: UTCDatetime
     require_previous_success: bool
@@ -209,6 +263,11 @@ class PrintQueueItemResponse(BaseModel):
     batch_id: int | None = None
     batch_name: str | None = None
 
+    # Cross-model alternatives (#671), in priority order. Empty for every
+    # ordinary item. Present until dispatch resolves one onto the row, after
+    # which library_file_id / target_model name the candidate that actually ran.
+    variants: list[QueueVariantSummary] = []
+
     # Shortest-job-first scheduling
     been_jumped: bool = False
 
@@ -220,6 +279,10 @@ class PrintQueueItemResponse(BaseModel):
     # "edit print → choose nozzle" UI; null on every model except O1C2
     # uploads from BambuStudio.
     nozzle_mapping: list[int] | None = None
+    # Which rack position each filament group prints from (#1784), as
+    # {group_id: 1-based position}. The operator's pick, re-checked against the
+    # live rack at dispatch; null means "assign them for me".
+    nozzle_rack_choice: dict[int, int] | None = None
 
     class Config:
         from_attributes = True
@@ -277,9 +340,11 @@ class PrintQueueBulkUpdate(BaseModel):
     use_ams: bool | None = None
     nozzle_offset_cali: TriState | None = None
     preheat_override: Literal["inherit", "on", "off"] | None = None
-    preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=60)
+    preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=MAX_CHAMBER_TEMP_C)
     # Auto-print G-code injection
     gcode_injection: bool | None = None
+    cost_center_id: int | None = None
+    estimated_cost: float | None = None
 
 
 class PrintQueueBulkUpdateResponse(BaseModel):
@@ -290,6 +355,20 @@ class PrintQueueBulkUpdateResponse(BaseModel):
     message: str
 
 
+class PrintBatchPlateTarget(BaseModel):
+    """How many runs of one plate an order wants (#342).
+
+    ``plate_id`` is the plate index inside the source 3MF, or null for a
+    single-plate file — matching ``PrintQueueItem.plate_id``. A target of 0 is
+    legal and means "this plate is not required (yet)".
+    """
+
+    plate_id: int | None = None
+    plate_name: str | None = None
+    quantity_target: int = Field(default=1, ge=0, le=999)
+    sort_order: int = 0
+
+
 class PrintBatchCreate(BaseModel):
     """Create a batch, either empty (multi-plate pre-batch flow) or by
     assigning existing pending queue items into it (manual "Group as batch")."""
@@ -301,6 +380,41 @@ class PrintBatchCreate(BaseModel):
     # the empty-batch flow (client passes the returned id on subsequent
     # addToQueue calls).
     item_ids: list[int] | None = None
+    # Per-plate targets. Omitted entirely by the pre-#342 flows, which produce
+    # a batch that reports progress but owes nothing.
+    plates: list[PrintBatchPlateTarget] | None = None
+    # Planning metadata. Projects own the heavier fields (BOM, attachments,
+    # tags); these two are the ones that are useless without a Project to
+    # hang them on, so the order carries them directly.
+    project_id: int | None = None
+    due_date: datetime | None = None
+    notes: str | None = None
+
+
+class PrintBatchUpdate(BaseModel):
+    """Edit an order's header or its per-plate targets while it runs.
+
+    Every field is optional; ``plates`` replaces the full target set when
+    given, so a plate omitted from the list has its target row removed.
+    """
+
+    name: str | None = None
+    status: Literal["active", "cancelled"] | None = None
+    plates: list[PrintBatchPlateTarget] | None = None
+    project_id: int | None = None
+    due_date: datetime | None = None
+    notes: str | None = None
+
+
+class PrintBatchDispatchRequest(BaseModel):
+    """Create queue items for the runs an order still owes."""
+
+    # Restrict to one plate. Null is a legitimate plate_id (single-plate file),
+    # so the caller opts in explicitly rather than us inferring from null.
+    plate_id: int | None = None
+    only_plate: bool = False
+    # Cap on how many items to create across all plates. None = everything owed.
+    limit: int | None = Field(default=None, ge=1, le=999)
 
 
 class PrintBatchUngroupResponse(BaseModel):
@@ -310,6 +424,28 @@ class PrintBatchUngroupResponse(BaseModel):
     message: str
 
 
+class PrintBatchPlateProgress(BaseModel):
+    """Per-plate progress within a batch."""
+
+    plate_id: int | None = None
+    plate_name: str | None = None
+    quantity_target: int = 0
+    dispatched: int = 0
+    remaining: int = 0
+    pending_count: int = 0
+    printing_count: int = 0
+    completed_count: int = 0
+    failed_count: int = 0
+    cancelled_count: int = 0
+    skipped_count: int = 0
+    # Measured from finished runs, never estimated from the file. Null until
+    # at least one run of this plate has produced a cost.
+    actual_cost: float | None = None
+    estimated_remaining_cost: float | None = None
+    filament_used_grams: float | None = None
+    print_time_seconds: int = 0
+
+
 class PrintBatchResponse(BaseModel):
     """Response for a print batch with progress stats."""
 
@@ -320,14 +456,30 @@ class PrintBatchResponse(BaseModel):
     quantity: int
     status: str
     created_at: UTCDatetime
+    completed_at: UTCDatetime | None = None
     created_by_id: int | None = None
     created_by_username: str | None = None
+    project_id: int | None = None
+    due_date: UTCDatetime | None = None
+    notes: str | None = None
     # Derived counts
     pending_count: int = 0
     printing_count: int = 0
     completed_count: int = 0
     failed_count: int = 0
     cancelled_count: int = 0
+    skipped_count: int = 0
+    # Planning roll-up. has_targets is false for batches created before
+    # per-plate targets existed: they report progress but owe nothing, and the
+    # dispatch endpoint is a no-op for them.
+    has_targets: bool = False
+    target_count: int = 0
+    remaining_count: int = 0
+    actual_cost: float | None = None
+    estimated_remaining_cost: float | None = None
+    filament_used_grams: float | None = None
+    print_time_seconds: int = 0
+    plates: list[PrintBatchPlateProgress] = []
 
     class Config:
         from_attributes = True

+ 114 - 0
backend/app/schemas/printer_ha_sensor.py

@@ -0,0 +1,114 @@
+"""Schemas for Home Assistant entities bound to a printer (#1148, #448)."""
+
+from datetime import datetime
+from typing import Literal
+
+from pydantic import BaseModel, Field, model_validator
+
+
+class PrinterHASensorBase(BaseModel):
+    printer_id: int
+    name: str = Field(..., min_length=1, max_length=100)
+    entity_id: str = Field(..., pattern=r"^(binary_sensor|sensor)\.[a-z0-9_]+$")
+    kind: Literal["binary", "numeric"] = "binary"
+    device_class: str | None = Field(default=None, max_length=32)
+    unit: str | None = Field(default=None, max_length=16)
+
+    alert_state: Literal["on", "off"] | None = None
+    alert_above: float | None = None
+    alert_below: float | None = None
+
+    block_print: bool = False
+    notify_on_alert: bool = False
+    show_on_printer_card: bool = True
+    sort_order: int = Field(default=0, ge=0, le=999)
+
+    @model_validator(mode="after")
+    def validate_kind_matches_entity(self) -> "PrinterHASensorBase":
+        domain = self.entity_id.split(".")[0]
+        expected = "binary" if domain == "binary_sensor" else "numeric"
+        if self.kind != expected:
+            raise ValueError(f"kind must be '{expected}' for a {domain} entity")
+
+        # Alert fields are per-kind: a threshold on a door contact and an
+        # on/off alert on a thermometer are both configuration the poller
+        # would silently ignore, so reject them at the edge instead.
+        if self.kind == "binary" and (self.alert_above is not None or self.alert_below is not None):
+            raise ValueError("alert_above/alert_below only apply to numeric sensors")
+        if self.kind == "numeric" and self.alert_state is not None:
+            raise ValueError("alert_state only applies to binary sensors")
+        if self.alert_above is not None and self.alert_below is not None and self.alert_below >= self.alert_above:
+            raise ValueError("alert_below must be lower than alert_above")
+
+        # An interlock or a notification with nothing to trigger on would never
+        # fire — that reads as a broken feature, not as a no-op.
+        if (self.block_print or self.notify_on_alert) and not self._has_alert_condition():
+            raise ValueError("block_print and notify_on_alert require an alert condition")
+        return self
+
+    def _has_alert_condition(self) -> bool:
+        return self.alert_state is not None or self.alert_above is not None or self.alert_below is not None
+
+
+class PrinterHASensorCreate(PrinterHASensorBase):
+    pass
+
+
+class PrinterHASensorUpdate(BaseModel):
+    """Partial update. Validated against the merged row in the route, because
+    the per-kind rules above need fields this payload may not carry."""
+
+    name: str | None = Field(default=None, min_length=1, max_length=100)
+    entity_id: str | None = Field(default=None, pattern=r"^(binary_sensor|sensor)\.[a-z0-9_]+$")
+    kind: Literal["binary", "numeric"] | None = None
+    device_class: str | None = Field(default=None, max_length=32)
+    unit: str | None = Field(default=None, max_length=16)
+    alert_state: Literal["on", "off"] | None = None
+    alert_above: float | None = None
+    alert_below: float | None = None
+    block_print: bool | None = None
+    notify_on_alert: bool | None = None
+    show_on_printer_card: bool | None = None
+    sort_order: int | None = Field(default=None, ge=0, le=999)
+
+
+class PrinterHASensorResponse(PrinterHASensorBase):
+    id: int
+    last_state: str | None = None
+    last_changed: datetime | None = None
+    last_checked: datetime | None = None
+    created_at: datetime
+    updated_at: datetime
+
+    class Config:
+        from_attributes = True
+
+
+class PrinterHASensorReading(BaseModel):
+    """One sensor's live state, as the printer card renders it."""
+
+    id: int
+    name: str
+    entity_id: str
+    kind: str
+    device_class: str | None = None
+    unit: str | None = None
+    # Raw HA state: "on"/"off" for binary, the numeric string for sensors.
+    # None when the entity is unavailable or has not been polled yet.
+    state: str | None = None
+    value: float | None = None  # numeric sensors only, parsed from state
+    alerting: bool = False
+    block_print: bool = False
+    reachable: bool = True
+    last_changed: datetime | None = None
+
+
+class HADisplayEntity(BaseModel):
+    """A bindable entity, as offered by the picker."""
+
+    entity_id: str
+    friendly_name: str
+    state: str | None = None
+    domain: str
+    device_class: str | None = None
+    unit_of_measurement: str | None = None

+ 20 - 1
backend/app/schemas/project.py

@@ -91,13 +91,24 @@ class ProjectStats(BaseModel):
 
 
 class ProjectChildPreview(BaseModel):
-    """Minimal project data for child preview."""
+    """A sub-project as listed on its parent's page.
+
+    The figures cover the child's *own* subtree, not just its own prints, so
+    the listed rows add up to the parent's roll-up minus the parent's own
+    prints (#1264).
+    """
 
     id: int
     name: str
     color: str | None
     status: str
     progress_percent: float | None = None
+    descendant_count: int = 0  # Sub-projects nested under this one, at any depth
+    total_archives: int = 0
+    completed_prints: int = 0
+    total_print_time_hours: float = 0.0
+    total_filament_grams: float = 0.0
+    total_cost: float = 0.0  # Filament + energy + BOM, matching the parent's cost card
 
 
 class ProjectResponse(BaseModel):
@@ -122,9 +133,13 @@ class ProjectResponse(BaseModel):
     parent_id: int | None = None
     parent_name: str | None = None  # For display
     children: list[ProjectChildPreview] = []
+    descendant_count: int = 0  # Sub-projects at any depth beneath this one (#1264)
     created_at: datetime
     updated_at: datetime
     stats: ProjectStats | None = None
+    # This project's numbers combined with every sub-project's. Null when there
+    # are none, since it would only repeat ``stats`` (#1264).
+    rollup_stats: ProjectStats | None = None
     url: str | None = None
     cover_image_filename: str | None = None
 
@@ -177,6 +192,10 @@ class ProjectListResponse(BaseModel):
     failed_count: int = 0  # Sum of quantities for failed prints
     queue_count: int = 0
     progress_percent: float | None = None
+    # Nesting (#1264) — the grid needs both to tell a sub-project apart from a
+    # top-level one without fetching every project's detail.
+    parent_id: int | None = None
+    child_count: int = 0  # Direct sub-projects only
     # Preview of archives (up to 5)
     archives: list[ArchivePreview] = []
     # #1155: card-level metadata

+ 132 - 2
backend/app/schemas/settings.py

@@ -1,8 +1,10 @@
 import json
+import re
 
 from pydantic import BaseModel, Field, ValidationInfo, field_validator
 
 from backend.app.schemas.print_queue import TriState
+from backend.app.utils.printer_models import MAX_CHAMBER_TEMP_C
 
 # Outbound service URLs validated on save, so a bad value is rejected at
 # configuration time with a clear message rather than failing opaquely at
@@ -18,6 +20,18 @@ from backend.app.schemas.print_queue import TriState
 # must be reachable on the public internet, on the stricter OIDC guard).
 LAN_SERVICE_URL_SETTINGS = ("ha_url", "obico_ml_url", "orcaslicer_api_url", "bambu_studio_api_url")
 
+# ``docker_compose_dir`` is unusual among the string settings: it is not
+# consumed by Bambuddy at all, it is interpolated into a shell command that
+# the Settings page invites the user to copy and paste into a root-capable
+# terminal (#2664). A value like ``/opt/bambuddy; rm -rf /`` would render as a
+# perfectly plausible-looking update command, so anyone with settings:update
+# could hand every admin a destructive one-liner to run. Restricting the field
+# to characters that occur in real paths removes that entirely; the frontend
+# double-quotes the value when it contains a space, which is safe precisely
+# because quotes, ``$`` and backticks cannot survive this pattern.
+_COMPOSE_DIR_ALLOWED = re.compile(r"^[\w \-./\\:~]+$", re.UNICODE)
+_COMPOSE_DIR_MAX_LEN = 512
+
 
 class AppSettings(BaseModel):
     """Application settings schema."""
@@ -219,6 +233,14 @@ class AppSettings(BaseModel):
         default="", description="External URL where Bambuddy is accessible (for notification images)"
     )
 
+    # Directory holding the user's docker-compose.yml, shown in the update
+    # instructions so the printed command can be pasted from anywhere (#2664).
+    # Empty means "omit the cd" — which is also the correct rendering when
+    # nothing could be detected, rather than guessing a path that fails.
+    docker_compose_dir: str = Field(
+        default="", description="Host directory containing docker-compose.yml, used in the update instructions"
+    )
+
     # Home Assistant integration for smart plug control
     ha_enabled: bool = Field(default=False, description="Enable Home Assistant integration for smart plug control")
     ha_url: str = Field(default="", description="Home Assistant URL (e.g., http://192.168.1.100:8123)")
@@ -264,6 +286,19 @@ class AppSettings(BaseModel):
         ),
     )
 
+    # Where slicing runs. Orthogonal to ``preferred_slicer``, which only says
+    # *which slicer binary* the sidecar drives: a browser engine is a different
+    # execution site, not a different binary choice. Kept as its own key so the
+    # two never have to encode impossible combinations.
+    #
+    # Only "sidecar" is implemented today; the slice modal offers a per-job
+    # choice when more than one engine is available, and hides the control
+    # entirely while there is only one.
+    slice_engine: str = Field(
+        default="sidecar",
+        description="Default execution site for slicing: 'sidecar' (server-side API) or 'browser'",
+    )
+
     # Slicer dispatch mode: when True, "Slice" actions open the in-app
     # SliceModal and call the slicer-API sidecar. When False (default), they
     # hand off to the user's local desktop slicer via URI scheme — preserving
@@ -359,6 +394,26 @@ class AppSettings(BaseModel):
         default=5, ge=1, le=60, description="Minutes between staggered printer groups"
     )
 
+    # Finance budget window settings
+    billing_enabled: bool = Field(
+        default=False,
+        description="Enable cost-center billing enforcement for print and queue operations",
+    )
+    printer_kill_switch_enabled: bool = Field(
+        default=False,
+        description="Immediately stop printer jobs that start without Bambuddy authorization",
+    )
+    finance_budget_reset_day: int = Field(
+        default=1,
+        ge=1,
+        le=31,
+        description="Day of month when monthly finance budget window resets (1-31, clamped for short months)",
+    )
+    finance_budget_reset_timezone: str = Field(
+        default="UTC",
+        description="IANA timezone for finance monthly budget reset calculation (e.g., Europe/Berlin)",
+    )
+
     # Plate-clear confirmation for queue scheduling
     require_plate_clear: bool = Field(
         default=False,
@@ -415,6 +470,42 @@ class AppSettings(BaseModel):
         le=1800,
         description="Additional hold time at temperature after the chamber reaches the target (or after max_wait_seconds elapses). 0 = no soak.",
     )
+    queue_keep_bed_warm: bool = Field(
+        default=False,
+        description=(
+            "While a printer is in FINISH state awaiting plate-clear and the next queued item requires "
+            "chamber heating, hold the bed hot so the chamber stays warm during the bed-clearing "
+            "window. The bed is the chamber's heating element here: the hold target is "
+            "queue_keep_warm_bed_temp, or the item's own bed_temperature when the slicer metadata "
+            "reports a higher one. Only fires for filaments with a non-zero chamber target "
+            "(ASA, ABS, PA, PC etc.); PLA/PETG prints are skipped automatically."
+        ),
+    )
+    queue_keep_warm_bed_temp: int = Field(
+        default=90,
+        ge=40,
+        le=110,
+        description=(
+            "Bed temperature (°C) used when the bed's job is to heat the chamber. 90 sustains "
+            "chamber warmth on enclosed printers and satisfies bed-threshold-linked aftermarket "
+            "chamber heaters (which typically activate at bed ≥ 80). Applies in two places: the "
+            "keep-warm hold between chamber-heated prints, and preheat when a chamber-heated "
+            "item's slicer metadata carries no bed temperature at all. A parsed bed temperature "
+            "higher than this always wins, so the bed is never driven cooler than the print needs."
+        ),
+    )
+    queue_keep_warm_max_minutes: int = Field(
+        default=120,
+        ge=5,
+        le=480,
+        description=(
+            "How long keep-warm may hold the bed on a printer waiting for its plate to be cleared. "
+            "When this elapses the bed is switched off, and the hold does not re-arm until the "
+            "printer next becomes a keep-warm candidate — so a plate nobody clears cannot leave the "
+            "bed hot indefinitely. Set it to how long you realistically take to reach the printer; "
+            "the only cost of it being too short is that the next print re-soaks from cold."
+        ),
+    )
 
     # User-configurable presets for the printer-card temperature / fan-speed
     # popovers. Each is a JSON array of exactly 3 ints (the "Off" button is
@@ -430,7 +521,7 @@ class AppSettings(BaseModel):
     )
     chamber_temp_presets: str = Field(
         default="",
-        description="JSON array of 3 chamber-temperature preset values in C (0-60). Empty = use defaults [35, 45, 60]",
+        description="JSON array of 3 chamber-temperature preset values in C (0-65). Empty = use defaults [35, 45, 60]",
     )
     fan_speed_presets: str = Field(
         default="",
@@ -587,6 +678,7 @@ class AppSettingsUpdate(BaseModel):
     mqtt_topic_prefix: str | None = None
     mqtt_use_tls: bool | None = None
     external_url: str | None = None
+    docker_compose_dir: str | None = None
     ha_enabled: bool | None = None
     ha_url: str | None = None
     ha_token: str | None = None
@@ -595,6 +687,7 @@ class AppSettingsUpdate(BaseModel):
     camera_view_mode: str | None = None
     preferred_slicer: str | None = None
     open_in_slicer: str | None = None
+    slice_engine: str | None = None
     use_slicer_api: bool | None = None
     orcaslicer_api_url: str | None = None
     bambu_studio_api_url: str | None = None
@@ -612,6 +705,10 @@ class AppSettingsUpdate(BaseModel):
     default_nozzle_offset_cali: TriState | None = None
     stagger_group_size: int | None = Field(default=None, ge=1, le=50)
     stagger_interval_minutes: int | None = Field(default=None, ge=1, le=60)
+    billing_enabled: bool | None = None
+    printer_kill_switch_enabled: bool | None = None
+    finance_budget_reset_day: int | None = Field(default=None, ge=1, le=31)
+    finance_budget_reset_timezone: str | None = None
     require_plate_clear: bool | None = None
     queue_shortest_first: bool | None = None
     queue_max_concurrent_uploads: int | None = Field(default=None, ge=1, le=16)
@@ -619,6 +716,9 @@ class AppSettingsUpdate(BaseModel):
     preheat_filament_targets: str | None = None
     preheat_max_wait_seconds: int | None = Field(default=None, ge=60, le=3600)
     preheat_soak_seconds: int | None = Field(default=None, ge=0, le=1800)
+    queue_keep_bed_warm: bool | None = None
+    queue_keep_warm_bed_temp: int | None = Field(default=None, ge=40, le=110)
+    queue_keep_warm_max_minutes: int | None = Field(default=None, ge=5, le=480)
     nozzle_temp_presets: str | None = None
     bed_temp_presets: str | None = None
     chamber_temp_presets: str | None = None
@@ -690,6 +790,36 @@ class AppSettingsUpdate(BaseModel):
             raise ValueError(str(exc)) from exc
         return v
 
+    @field_validator("docker_compose_dir")
+    @classmethod
+    def validate_docker_compose_dir(cls, v: str | None) -> str | None:
+        """Keep the copy-and-paste update command free of shell injection (#2664).
+
+        Validated on the write path only. Doing it on ``AppSettings`` as well
+        would mean a single bad row — however it got there — 500s the entire
+        settings GET and takes the app down with it, which is a worse outcome
+        than rendering a string that has to be pasted into a shell by hand to
+        do anything at all.
+        """
+        if v is None or not v.strip():
+            return v
+        candidate = v.strip()
+        if len(candidate) > _COMPOSE_DIR_MAX_LEN:
+            raise ValueError(f"Compose directory must be at most {_COMPOSE_DIR_MAX_LEN} characters")
+        if not _COMPOSE_DIR_ALLOWED.match(candidate):
+            raise ValueError(
+                "Compose directory may only contain path characters (letters, digits, space, and - _ . / \\ : ~)"
+            )
+        # A trailing backslash is the one survivor that would still break the
+        # frontend's double-quoting: `cd "/opt/bam buddy\"` escapes the closing
+        # quote and swallows the rest of the line. Harmless (the shell just
+        # waits for a terminator rather than running anything) but the user
+        # would be left staring at a continuation prompt, so refuse it here
+        # instead of shipping a command that cannot work.
+        if candidate.endswith("\\"):
+            raise ValueError("Compose directory must not end with a backslash")
+        return candidate
+
     @field_validator("gcode_snippets")
     @classmethod
     def validate_gcode_snippets(cls, v: str | None) -> str | None:
@@ -759,7 +889,7 @@ class AppSettingsUpdate(BaseModel):
     @field_validator("chamber_temp_presets")
     @classmethod
     def validate_chamber_temp_presets(cls, v: str | None) -> str | None:
-        return cls._validate_preset_triple(v, "chamber_temp_presets", 0, 60)
+        return cls._validate_preset_triple(v, "chamber_temp_presets", 0, MAX_CHAMBER_TEMP_C)
 
     @field_validator("fan_speed_presets")
     @classmethod

+ 42 - 1
backend/app/schemas/slicer.py

@@ -1,6 +1,6 @@
 """Pydantic schemas for slice requests."""
 
-from typing import Literal
+from typing import Any, Literal
 
 from pydantic import BaseModel, Field, model_validator
 
@@ -93,6 +93,19 @@ class SliceRequest(BaseModel):
             "else is ignored. ``None``/empty means a plain profile slice."
         ),
     )
+    process_overrides: dict[str, Any] | None = Field(
+        default=None,
+        description=(
+            "The user's own process-setting edits from the slice modal's settings "
+            "panel, as a sparse ``{option_key: value}`` map (layer height, wall "
+            "count, supports, speeds — OrcaSlicer's process parameter set). Written "
+            "into the process JSON *after* the source's support settings and the "
+            "designer's carried tweaks, so an explicit choice here wins over both. "
+            "Values are normalised to the string forms a process preset stores; "
+            "keys that aren't valid config keys are dropped rather than failing "
+            "the slice. ``None``/empty leaves the picked preset untouched."
+        ),
+    )
     use_embedded_settings: bool = Field(
         default=False,
         description=(
@@ -119,6 +132,27 @@ class SliceRequest(BaseModel):
             "process preset unchanged (#1337)."
         ),
     )
+    auto_orient: bool = Field(
+        default=False,
+        description=(
+            "Let the slicer pick each object's orientation before slicing "
+            "(BambuStudio / OrcaSlicer ``--orient 1``, the GUI's 'Auto orient'). "
+            "Off by default: it rotates geometry, so a model the designer laid "
+            "flat on purpose would silently change. Applies on the embedded-"
+            "settings path too — it is a CLI action, not a profile value (#2548)."
+        ),
+    )
+    auto_arrange: bool = Field(
+        default=False,
+        description=(
+            "Let the slicer lay the objects out on the plate before slicing "
+            "(``--arrange 1``, the GUI's 'Auto arrange'). Off by default: it "
+            "repositions objects, discarding a deliberate layout. Forced on "
+            "regardless for cross-nozzle-class re-slices, where the source's "
+            "coordinates land in the target's dead zone (#1493). Applies on the "
+            "embedded-settings path too (#2548)."
+        ),
+    )
 
     @model_validator(mode="after")
     def normalise_preset_refs(self) -> "SliceRequest":
@@ -177,6 +211,13 @@ class SliceResponse(BaseModel):
     filament_used_g: float
     filament_used_mm: float
     used_embedded_settings: bool = False
+    # Set when the source lives in an external folder that could not receive
+    # the result (read-only, unreachable, not writable), so the file went to
+    # managed storage instead. Names which of those it was. ``None`` on every
+    # normal slice. Reported rather than silently absorbed: filing the output
+    # somewhere the user isn't looking, with no signal, is what made #2810
+    # impossible to reproduce from the UI.
+    external_write_fallback: str | None = None
 
 
 class SliceArchiveResponse(BaseModel):

+ 2 - 2
backend/app/schemas/slicer_presets.py

@@ -38,8 +38,8 @@ class UnifiedPreset(BaseModel):
     detail is fetched — rate limits) and standard (the sidecar's bundled
     listing doesn't expose it). The SliceModal uses it to filter the
     process / filament dropdowns by the selected printer (#1325); when it is
-    ``None`` the modal falls back to the user's uploaded Slicer Bundles, which
-    map each printer to the presets it ships.
+    ``None`` the modal falls back to matching the preset name against the
+    ``@BBL <code>`` printer-model registry.
     """
 
     id: str

+ 14 - 1
backend/app/services/archive.py

@@ -18,6 +18,7 @@ from backend.app.core.tasks import spawn_background_task
 from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
 from backend.app.models.printer import Printer
+from backend.app.utils.filename import clean_display_name
 from backend.app.utils.safe_path import PathTraversalError, safe_join_under
 
 logger = logging.getLogger(__name__)
@@ -1140,6 +1141,7 @@ class ArchiveService:
         created_by_id: int | None = None,
         original_filename: str | None = None,
         project_id: int | None = None,
+        cost_center_id: int | None = None,
         subtask_id: str | None = None,
         prefer_filename_for_name: bool = False,
         plate_id: int | None = None,
@@ -1331,7 +1333,17 @@ class ArchiveService:
             file_size=dest_file.stat().st_size,
             content_hash=content_hash,
             thumbnail_path=thumbnail_path,
-            print_name=display_stem if prefer_filename_for_name else (metadata.get("print_name") or display_stem),
+            # clean_display_name because the 3MF's own metadata reaches this
+            # verbatim, and a control character in it renders nowhere and
+            # truncates somewhere (#2832). The schema does the same for names
+            # arriving over the API. Cleaned before the fallback rather than
+            # after it, so an embedded name that is only whitespace still falls
+            # through to the filename instead of leaving the archive nameless.
+            print_name=(
+                clean_display_name(display_stem)
+                if prefer_filename_for_name
+                else (clean_display_name(metadata.get("print_name")) or clean_display_name(display_stem))
+            ),
             print_time_seconds=metadata.get("print_time_seconds"),
             filament_used_grams=metadata.get("filament_used_grams"),
             filament_type=metadata.get("filament_type"),
@@ -1354,6 +1366,7 @@ class ArchiveService:
             created_by_id=created_by_id,
             project_id=project_id,
             library_file_id=library_file_id,
+            cost_center_id=cost_center_id,
             subtask_id=subtask_id,
             plate_id=plate_id,
         )

+ 168 - 0
backend/app/services/bambu_cloud.py

@@ -124,6 +124,110 @@ def _detect_cloudflare_challenge(response) -> str | None:
     return None
 
 
+# Bambu's own anti-abuse layer — distinct from the Cloudflare edge above —
+# answers a request it has flagged with HTTP 418 and a challenge body:
+#
+#     {"captchaId": "...", "error": "We need you to confirm you are not a robot"}
+#
+# The flag is keyed to the source IP and covers api.bambulab.com as a whole:
+# the same 418 turns up on the login endpoint and on the design-service
+# endpoints MakerWorld imports use. It clears on its own after a few hours of
+# quiet traffic, and there is no server-side solve — a CAPTCHA is designed to be
+# unanswerable without a real browser, and the challenge id is of no use to us
+# because we have nowhere to render the widget.
+#
+# It reaches ``login_request`` as a perfectly well-formed JSON body, so
+# ``_detect_cloudflare_challenge`` above never fires on it. Before #2790 the
+# generic error path then lifted Bambu's sentence out of ``error`` and showed it
+# as a bare toast: the reporter saw "We need you to confirm you are not a robot"
+# with no challenge, no explanation and nothing to click, and filed it as a
+# Bambuddy bug.
+_CAPTCHA_HTTP_STATUS = 418
+
+# Markers that identify a 418 as the CAPTCHA challenge rather than some other
+# refusal. ``captchaId`` is the reliable one; the wording is matched too because
+# Bambu has shipped the challenge under more than one phrasing.
+_CAPTCHA_BODY_MARKERS = ("captchaid", "captcha", "robot")
+
+CAPTCHA_USER_MESSAGE = (
+    "Bambu Cloud is challenging this network with a CAPTCHA before it will accept a sign-in, "
+    "and there is no way to answer it from Bambuddy. Your email and password are not the "
+    "problem. The block is tied to your public IP address and normally clears by itself within "
+    "a few hours — retrying repeatedly extends it. To sign in now, use 'Use access token "
+    "instead' and paste a token taken from a browser session."
+)
+
+# How long to stop sending sign-in requests to a Bambu region after it answered
+# with a CAPTCHA challenge. The reporter's log shows four attempts in eighteen
+# seconds, which is exactly the traffic pattern that deepens the block: every
+# extra request is more evidence for the thing that flagged us. Five minutes is
+# short against the hours the block itself lasts — the point is not to wait it
+# out here, only to stop Bambuddy from making it worse while the user reads the
+# explanation.
+_CAPTCHA_COOLOFF_SECONDS = 300.0
+
+# API base URL -> monotonic time its cool-off expires. Keyed by base URL because
+# the block lives at the edge in front of one region: being challenged on
+# api.bambulab.com says nothing about api.bambulab.cn.
+_captcha_blocked_until: dict[str, float] = {}
+
+
+def is_captcha_challenge(response) -> bool:
+    """Whether Bambu answered with an anti-abuse CAPTCHA challenge.
+
+    Requires the 418 status *and* a challenge marker in the body, so an
+    unrelated 418 is not reported to the user as "solve a CAPTCHA" — that would
+    send them looking for a widget that was never there, which is the exact
+    confusion #2790 is about. Callers that want to say something about a bare
+    418 must handle it themselves.
+
+    Shared by the Bambu Cloud and MakerWorld services: same edge, same body.
+    """
+    try:
+        status = int(getattr(response, "status_code", 0) or 0)
+    except (TypeError, ValueError):
+        return False
+    if status != _CAPTCHA_HTTP_STATUS:
+        return False
+    try:
+        data = response.json()
+    except Exception:
+        data = None
+    if isinstance(data, dict):
+        # Field *names* count as well as their text: the challenge is
+        # identified by carrying a ``captchaId`` at all, whatever it says.
+        parts = [str(key) for key in data]
+        parts += [str(data[key]) for key in ("captchaId", "error", "message", "detail") if data.get(key)]
+        haystack = " ".join(parts).lower()
+    else:
+        # Not JSON (or not an object) — fall back to the raw body so a
+        # challenge served as HTML is still recognised rather than reported as
+        # an unexplained failure.
+        try:
+            haystack = (response.text or "").lower()
+        except Exception:
+            return False
+    return any(marker in haystack for marker in _CAPTCHA_BODY_MARKERS)
+
+
+def captcha_cooloff_active(base_url: str) -> bool:
+    """Whether sign-in requests to ``base_url`` are still held back after a
+    CAPTCHA challenge. Expired entries are dropped on the way past, so the dict
+    cannot grow past one entry per region."""
+    deadline = _captcha_blocked_until.get(base_url)
+    if deadline is None:
+        return False
+    if time.monotonic() >= deadline:
+        del _captcha_blocked_until[base_url]
+        return False
+    return True
+
+
+def note_captcha_challenge(base_url: str) -> None:
+    """Start the cool-off for ``base_url`` after a challenge was seen."""
+    _captcha_blocked_until[base_url] = time.monotonic() + _CAPTCHA_COOLOFF_SECONDS
+
+
 # The `/v1/iot-service/api/slicer/setting` endpoint subtree — the plural GET
 # for the list, the singular GET/DELETE for a specific preset by setting_id, and
 # the POST for create — requires a `version` query parameter in the XX.YY.ZZ.WW
@@ -317,12 +421,62 @@ class BambuCloudService:
             headers["Authorization"] = f"Bearer {self.access_token}"
         return headers
 
+    def _captcha_refusal(self) -> dict:
+        """The result every sign-in call returns while Bambu is challenging us.
+
+        ``reason`` is what lets the UI tell this apart from a wrong password and
+        render the explanation next to the access-token route, instead of
+        flashing Bambu's own one-liner as a toast that then disappears (#2790).
+        """
+        return {
+            "success": False,
+            "needs_verification": False,
+            "reason": "captcha",
+            "message": CAPTCHA_USER_MESSAGE,
+        }
+
+    def _captcha_cooloff_holds(self, origin: str | None = None) -> bool:
+        """Whether to refuse a sign-in locally because Bambu just challenged us.
+
+        Keyed by the origin the call actually goes to. The TOTP step talks to
+        ``bambulab.com`` while everything else talks to ``api.bambulab.com``, and
+        a challenge seen on one must not strand a user halfway through a
+        two-factor sign-in on the other.
+        """
+        origin = origin or self.base_url
+        if not captcha_cooloff_active(origin):
+            return False
+        logger.warning(
+            "Bambu Cloud is challenging this network with a CAPTCHA — not sending the sign-in to %s. "
+            "The challenge cannot be answered from Bambuddy and normally clears within a few hours.",
+            origin,
+        )
+        return True
+
+    def _note_captcha(self, response, origin: str | None = None) -> bool:
+        """Record and log a CAPTCHA challenge. Returns whether it was one."""
+        if not is_captcha_challenge(response):
+            return False
+        origin = origin or self.base_url
+        logger.warning(
+            "Bambu Cloud is challenging this network with a CAPTCHA (HTTP %s from %s). Sign-in cannot "
+            "complete until the challenge clears; pausing sign-in requests for %.0fs so retries do not "
+            "extend the block.",
+            response.status_code,
+            origin,
+            _CAPTCHA_COOLOFF_SECONDS,
+        )
+        note_captcha_challenge(origin)
+        return True
+
     async def login_request(self, email: str, password: str) -> dict:
         """
         Initiate login - this will trigger either email verification or TOTP prompt.
 
         Returns dict with login status, verification type, and tfaKey if needed.
         """
+        if self._captcha_cooloff_holds():
+            return self._captcha_refusal()
         try:
             response = await self._client.post(
                 f"{self.base_url}/v1/user-service/user/login",
@@ -333,6 +487,9 @@ class BambuCloudService:
                 },
             )
 
+            if self._note_captcha(response):
+                return self._captcha_refusal()
+
             try:
                 data = response.json()
             except Exception as json_err:
@@ -388,6 +545,8 @@ class BambuCloudService:
         """
         Complete login with email verification code.
         """
+        if self._captcha_cooloff_holds():
+            return self._captcha_refusal()
         try:
             response = await self._client.post(
                 f"{self.base_url}/v1/user-service/user/login",
@@ -398,6 +557,9 @@ class BambuCloudService:
                 },
             )
 
+            if self._note_captcha(response):
+                return self._captcha_refusal()
+
             try:
                 data = response.json()
             except Exception as json_err:
@@ -472,6 +634,9 @@ class BambuCloudService:
             web_origin = "https://bambulab.cn" if "bambulab.cn" in self.base_url else "https://bambulab.com"
             tfa_url = f"{web_origin}/api/sign-in/tfa"
 
+            if self._captcha_cooloff_holds(web_origin):
+                return self._captcha_refusal()
+
             # #2696: the web origin is CSRF-protected (double submit). Without
             # both halves the endpoint 403s before it ever evaluates the code,
             # which surfaced to users as a permanent, misleading "Invalid code".
@@ -509,6 +674,9 @@ class BambuCloudService:
                 f"TOTP verify response: status={response.status_code}, body={response.text[:200] if response.text else '(empty)'}"
             )
 
+            if self._note_captcha(response, web_origin):
+                return self._captcha_refusal()
+
             # Handle empty response
             if not response.text or not response.text.strip():
                 logger.warning("TOTP verification returned empty response (status %s)", response.status_code)

+ 129 - 7
backend/app/services/bambu_ftp.py

@@ -88,6 +88,37 @@ class DeleteResult(Enum):
     FAILED = "failed"
 
 
+# How long to stop opening FTPS connections to a printer after its TLS
+# handshake failed (#2780).
+#
+# ``WRONG_VERSION_NUMBER`` on port 990 means the printer answered with
+# something that is not a TLS record at all, so no path, retry or SSL option
+# gets further. Two support bundles show that state lasting for days: one X2D
+# served clean FTPS for five days, flipped on 2026-07-19, and then failed every
+# single handshake for the next eight (zero successes, 3511 failures).
+#
+# What it is NOT is a wedged file service, which is what this comment used to
+# claim. #2780's reporter power-cycled both affected printers and the state
+# survived it, and ``openssl s_client`` against the same port completes a clean
+# handshake and returns a valid certificate while Bambuddy is failing. The
+# leading theory is now a connection-count refusal — vsFTPd answers one in
+# cleartext, which is exactly this error to an implicit-TLS client, and answers
+# the global limit by accepting and never speaking, which is the handshake
+# timeout we also see. Unproven: confirming it needs a capture taken while a
+# printer is in the failing state.
+#
+# Without a gate every candidate path re-runs the same doomed handshake: the
+# 3MF lookup alone walks 6 filename variants x 5 directories x 4 retries, and
+# the cover and timelapse scans run their own sweeps on top. That is where
+# those thousands of failures come from — one wedged printer, hammered.
+#
+# Five minutes is short enough that a power-cycled printer is picked up on the
+# next print (and any successful connect clears the gate immediately), long
+# enough that a wedged one is contacted twice an hour instead of hundreds of
+# times a minute.
+_HANDSHAKE_COOLOFF_SECONDS = 300.0
+
+
 class FileNotOnPrinterError(Exception):
     """Raised when a remote FTP path returns 550 (file not found).
 
@@ -190,6 +221,10 @@ class BambuFTPClient:
     # Maps IP -> "prot_p" or "prot_c"
     _mode_cache: dict[str, str] = {}
 
+    # Printers whose FTPS handshake just failed, mapped to the monotonic time
+    # their cool-off expires. See ``_HANDSHAKE_COOLOFF_SECONDS``.
+    _handshake_blocked_until: dict[str, float] = {}
+
     def __init__(
         self,
         ip_address: str,
@@ -233,8 +268,36 @@ class BambuFTPClient:
         # Default: try prot_p first (will fall back if needed)
         return False
 
+    @classmethod
+    def handshake_blocked(cls, ip_address: str) -> bool:
+        """True while *ip_address* is inside its post-handshake-failure cool-off.
+
+        Public so a caller sweeping many candidate paths can stop after the
+        first one rather than walking the rest against a printer that cannot
+        complete a TLS handshake (#2780).
+        """
+        deadline = cls._handshake_blocked_until.get(ip_address)
+        if deadline is None:
+            return False
+        if time.monotonic() >= deadline:
+            # Drop it on the way past rather than leaving an entry per printer
+            # this process has ever failed against.
+            del cls._handshake_blocked_until[ip_address]
+            return False
+        return True
+
     def connect(self) -> bool:
-        """Connect to the printer FTP server (implicit FTPS on port 990)."""
+        """Connect to the printer FTP server (implicit FTPS on port 990).
+
+        Returns False without touching the network while the printer is inside
+        the cool-off a previous TLS handshake failure opened (#2780).
+        """
+        if self.handshake_blocked(self.ip_address):
+            logger.debug(
+                "FTP connect to %s skipped: FTPS handshake failed recently, cooling off",
+                self.ip_address,
+            )
+            return False
         try:
             use_prot_c = self._should_use_prot_c()
             from backend.app.services.ftp_profiles import get_ftp_profile
@@ -270,28 +333,77 @@ class BambuFTPClient:
             return True
         except ftplib.error_perm as e:
             logger.warning("FTP connection permission error to %s: %s", self.ip_address, e)
-            self._ftp = None
+            self._abandon_connection()
             return False
         except TimeoutError as e:
             logger.warning("FTP connection timed out to %s: %s", self.ip_address, e)
-            self._ftp = None
+            self._abandon_connection()
             return False
         except ssl.SSLError as e:
-            logger.warning("FTP SSL error connecting to %s: %s", self.ip_address, e)
-            self._ftp = None
+            # Not a transient failure and not something another path or another
+            # retry can route around: the printer's file service answered port
+            # 990 with something that isn't TLS. Say so once and stop knocking
+            # for a while (#2780).
+            #
+            # Deliberately no advice about what to do. This message used to
+            # tell the operator to restart the printer; #2780's reporter did
+            # that twice, to no effect, and a single manual connect to the
+            # same printer completes a clean handshake. We do not yet know the
+            # trigger, so stating the observation and stopping there beats
+            # sending people to do the one thing already known not to work.
+            logger.warning(
+                "FTP SSL error connecting to %s: %s — the printer answered port %s with something "
+                "that is not TLS, so print files, covers and timelapses cannot be fetched from it. "
+                "Pausing FTP to this printer for %.0fs.",
+                self.ip_address,
+                e,
+                self.FTP_PORT,
+                _HANDSHAKE_COOLOFF_SECONDS,
+            )
+            self._handshake_blocked_until[self.ip_address] = time.monotonic() + _HANDSHAKE_COOLOFF_SECONDS
+            self._abandon_connection()
             return False
         except (OSError, ftplib.Error) as e:
             logger.warning("FTP connection failed to %s: %s (type: %s)", self.ip_address, e, type(e).__name__)
-            self._ftp = None
+            self._abandon_connection()
             return False
 
+    def _abandon_connection(self) -> None:
+        """Drop a connection that never became usable, closing its socket.
+
+        Every failure path in :meth:`connect` used to clear ``self._ftp`` and
+        nothing else, leaving a connected socket for the garbage collector.
+        That is survivable once; it is not survivable at this volume. A single
+        print used to walk ~110 candidate paths, so a printer refusing FTPS
+        got ~110 sockets opened and abandoned in a couple of minutes, and one
+        support bundle recorded 1813 of them in a day (#2780). If the refusal
+        is the printer running out of connection slots -- which fits the
+        evidence better than a wedged service, since a single manual connect
+        to the same printer succeeds -- then abandoning sockets is not just
+        untidy, it is what keeps the printer refusing.
+
+        Uses ``close()`` rather than ``quit()``: QUIT is a command, and there
+        is no working control channel to send it on.
+        """
+        ftp = self._ftp
+        self._ftp = None
+        if ftp is None:
+            return
+        try:
+            ftp.close()
+        except (OSError, ftplib.Error, EOFError):
+            pass  # Best-effort; the socket may already be gone
+
     def disconnect(self):
         """Disconnect from the FTP server."""
         if self._ftp:
             try:
                 self._ftp.quit()
             except (OSError, ftplib.Error, EOFError):
-                pass  # Best-effort FTP cleanup; connection may already be closed
+                # ``quit()`` sends QUIT and only then closes; when the send
+                # raises, ftplib never reaches its own close and the socket
+                # stays open. Close it here rather than leaving it to the GC.
+                self._abandon_connection()
             self._ftp = None
 
     def list_files(self, path: str = "/") -> list[dict]:
@@ -816,6 +928,16 @@ class BambuFTPClient:
         return result if result else None
 
 
+def ftps_handshake_blocked(ip_address: str) -> bool:
+    """True while this printer's FTPS handshake cool-off is still running.
+
+    Callers that walk a list of candidate paths use this to give up on the
+    remaining candidates: the failure is at the transport, below any path, so
+    every one of them would fail identically (#2780).
+    """
+    return BambuFTPClient.handshake_blocked(ip_address)
+
+
 # Shared 3MF download cache (#972).
 #
 # Both the cover thumbnail endpoint (api/routes/printers.py) and the archive

+ 721 - 13
backend/app/services/bambu_mqtt.py

@@ -40,6 +40,22 @@ _AMS_MODULE_PREFIXES = ("ams/", "n3f/", "n3s/")
 # printer_manager.ACTIVE_PRINT_STATES and print_scheduler._ACTIVE_PRINT_STATES.
 _ACTIVE_PRINT_STATES = frozenset({"PREPARE", "SLICING", "RUNNING", "PAUSE"})
 
+# AMS dry_status phases (info bits 4-7) in which a drying cycle is still live, so
+# a dry_time of 0 alongside one of them is a transient rather than a completion
+# (#2759). 0=Off, 4=Stopping and 5=Error all mean the cycle is over or ending and
+# are deliberately excluded — those SHOULD end it.
+_ACTIVE_DRY_STATUSES = frozenset({1, 2, 3})  # Checking, Drying, Cooling
+
+# A drying cycle that runs to term ends with its countdown all but exhausted, so
+# the last dry_time we saw before the drop to 0 tells us whether the firmware
+# ended the cycle on schedule or aborted it. More than this many minutes still on
+# the clock means it was cut short, and the firmware's own reason codes are worth
+# capturing at INFO — #2770 aborted a 12-hour cycle 20 minutes in (700 minutes
+# left), and the log said only "drying complete", so the report carried no
+# evidence of why. The margin absorbs a stale last observation between AMS
+# pushes; it is not a judgement about how short "short" is.
+_EARLY_DRY_END_MINUTES = 5
+
 # CONNACK reason codes that mean the printer actively refused our credentials,
 # as opposed to being unreachable or busy. Bambu speaks MQTT 3.1.1, whose
 # single-byte CONNACK return codes paho maps onto the v5 reason-code space:
@@ -258,6 +274,344 @@ def apply_tray_exist_bits(
     return cleared
 
 
+# --- H2C nozzle-rack dispatch mapping (#2800) -------------------------------
+#
+# Physical nozzle IDs the H2C reports for its six rack slots, verified on
+# hardware. They sit well clear of the fixed hotend's own physical ID, so a
+# rack position is never mistakable for the nozzle on the other carriage.
+#
+# Extruder indices are a different namespace that happens to overlap these
+# low numbers -- index 1 means the rack, physical ID 1 means the fixed hotend.
+# Nothing below may pass a value from one namespace to the other untranslated;
+# doing exactly that is what #2800 was.
+_RACK_NOZZLE_IDS = frozenset(range(16, 22))
+
+# BambuStudio dispatches a fixed-length nozzle_mapping on rack models: one
+# physical nozzle ID per filament slot, -1 for slots the plate does not print.
+#
+# Briefly changed to the plate's own slot count on the strength of a single
+# 3-entry capture, then changed back: Studio's dispatch of a real 3-filament
+# project print on the maintainer's H2C is 32 entries ([16, 1, 18, -1 x29],
+# captured 2026-08-13 17:20, and that print completed). The 3-entry capture was
+# a calibration job, so the length varies with whatever Studio is doing rather
+# than with the filament count -- which makes it the wrong thing to derive.
+_RACK_WIRE_SLOTS = 32
+
+# The two carriages, as extruder indices in the form the queue stores (already
+# translated through the file's physical_extruder_map).
+#
+# Measured on the maintainer's H2C 2026-08-14, from three sources that agree:
+#
+#   - telemetry: ``ams_extruder_map {'0': 1, '1': 0, '2': 0}`` -- AMS 0 feeds
+#     extruder 1, AMS 1 and 2 feed extruder 0;
+#   - BambuStudio's own dispatch of a plate using all three units sent AMS 0's
+#     filament to physical nozzle 1 and AMS 1's to rack positions 16 and 18,
+#     and that print completed. So extruder 1 is the fixed hotend and extruder
+#     0 is the rack;
+#   - our own constants were internally inconsistent about it: physical nozzle
+#     id N sits on extruder N (see the L/R split in PrintersPage), and
+#     ``_FIXED_NOZZLE_ID`` is 1, which cannot be reconciled with a fixed
+#     extruder index of 0.
+#
+# These were the other way round until then, which is what dispatched a plate
+# to the carriage that had not been levelled and printed its first layer in
+# mid-air. That value came from #2800, where dispatching [17, -1, -1, 1] printed
+# in mid-air and [1, -1, -1, 17] printed correctly -- but that A/B measured
+# which *wire* worked, and the extruder indices were only inferred from it by
+# pairing with a slot_extruders list the then-buggy 3MF reader had produced. The
+# wire result stands; the inference from it did not.
+_FIXED_EXTRUDER_ID = 1
+_RACK_EXTRUDER_ID = 0
+
+# The fixed hotend's physical ID, which is *not* its extruder index. The same
+# hardware A/B ruled the index out: [0, -1, -1, 17] was rejected by the printer
+# outright, which would not start the job at all. Native BambuStudio captures
+# of a mixed plate agree -- [1, 17, ...], and [17, 1, ...] once the filament
+# slot order is swapped, so the fixed side is 1 whichever slot it lands in.
+_FIXED_NOZZLE_ID = 1
+
+
+def resolve_rack_nozzle_mapping(
+    slot_extruders: list[int],
+    rack_nozzle_id: int | None,
+) -> list[int] | None:
+    """Expand a per-slot extruder mapping into an H2C physical nozzle_mapping.
+
+    ``slot_extruders`` is the compact form stored on the queue item: MQTT
+    extruder index per filament slot (index 0 = slot 1), -1 for a slot the
+    plate does not print. ``rack_nozzle_id`` is the rack position the printer
+    reports as live.
+
+    Returns a ``_RACK_WIRE_SLOTS``-long list of physical nozzle IDs, or None
+    when the mapping cannot be resolved with confidence -- in which case the
+    caller omits the field entirely and the firmware falls back to its own
+    nozzle pick, exactly as it did before this translation existed. Omitting
+    is deliberately the failure mode: a *wrong* physical ID makes the printer
+    level with one nozzle and print with another several millimetres off the
+    bed, which is far worse than letting the firmware choose.
+
+    Returns None specifically when:
+
+    - a slot needs the rack but the printer has not reported a live rack
+      position (mid-swap, or a stale connection);
+    - no slot needs the rack at all. BambuStudio omits nozzle_mapping entirely
+      for a plate sliced for the fixed hotend only (#2800 capture), so this
+      matches it rather than naming a nozzle it does not have to name;
+    - a slot names a carriage that is neither of the two an H2C has, which
+      means the file was mapped for a machine this translation does not model;
+    - the plate needs more slots than the wire format carries;
+    - the input is not a list of whole numbers.
+
+    Total by construction: it raises nothing, because the only caller is
+    building an MQTT print command with no exception handler above it and the
+    queue item has already been committed as `printing` by then. An
+    unparseable input has to degrade to "let the firmware pick", not to a job
+    wedged in a state no print will ever leave.
+    """
+    if not isinstance(slot_extruders, list) or not slot_extruders:
+        return None
+    if len(slot_extruders) > _RACK_WIRE_SLOTS:
+        return None
+    if not isinstance(rack_nozzle_id, int) or isinstance(rack_nozzle_id, bool):
+        return None
+    if rack_nozzle_id not in _RACK_NOZZLE_IDS:
+        return None
+
+    # Normalise first so the checks below, and the values that reach the wire,
+    # are known ints. bool is an int subclass and would otherwise serialise as
+    # a JSON `true`; None means "slot not printed" and is folded into -1.
+    normalised: list[int] = []
+    for extruder in slot_extruders:
+        if extruder is None:
+            normalised.append(-1)
+        elif isinstance(extruder, int) and not isinstance(extruder, bool):
+            normalised.append(extruder)
+        else:
+            return None
+
+    if _RACK_EXTRUDER_ID not in normalised:
+        return None
+
+    wire = [-1] * _RACK_WIRE_SLOTS
+    for index, extruder in enumerate(normalised):
+        if extruder < 0:
+            continue
+        if extruder == _RACK_EXTRUDER_ID:
+            wire[index] = rack_nozzle_id
+        elif extruder == _FIXED_EXTRUDER_ID:
+            wire[index] = _FIXED_NOZZLE_ID
+        else:
+            # An H2C has these two carriages and no others. A third index is a
+            # file mapped for something else, and forwarding it raw would name
+            # a physical nozzle by an index that does not identify one.
+            return None
+    return wire
+
+
+# A rack position as the operator counts it (and as the printer card and
+# BambuStudio both label it) is 1-based; the physical nozzle id is 15 higher.
+# Measured 2026-08-14: a plate dispatched with the operator picking R1 and R2
+# sent 16 and 17, and the same plate picking R1 and R3 sent 16 and 18.
+_RACK_POSITION_BASE = 15
+RACK_POSITIONS = tuple(range(1, len(_RACK_NOZZLE_IDS) + 1))
+
+
+def rack_position_to_nozzle_id(position: int) -> int | None:
+    """Physical nozzle id for a 1-based rack position, or None if out of range."""
+    if not isinstance(position, int) or isinstance(position, bool):
+        return None
+    if position not in RACK_POSITIONS:
+        return None
+    return _RACK_POSITION_BASE + position
+
+
+def _rack_slot_is_eligible(slot: dict, diameter: str, volume_type: str) -> bool:
+    """Whether a live rack slot can print a group wanting this nozzle.
+
+    Mirrors the filter BambuStudio applies in its own picker: the position has
+    to hold a nozzle at all, and that nozzle has to match the slice's diameter
+    and flow type. A mismatch here is not cosmetic -- it is the printer being
+    asked to lay down a 0.4 extrusion through a 0.2 orifice.
+    """
+    if not isinstance(slot, dict):
+        return False
+    slot_diameter = str(slot.get("diameter") or "").strip()
+    slot_type = str(slot.get("type") or "").strip()
+    if not slot_diameter and not slot_type:
+        return False  # empty position
+
+    # "0.40" and "0.4" are the same nozzle spelled two ways -- the 3MF pads,
+    # the printer does not.
+    try:
+        if round(float(slot_diameter), 2) != round(float(diameter), 2):
+            return False
+    except (TypeError, ValueError):
+        return False
+
+    # Flow type: the printer reports a code ("HS", "HH01"), the slice reports a
+    # name ("Standard", "High Flow"). Compared only when both are stated, so a
+    # printer that omits the code is not thereby ruled ineligible.
+    wanted = volume_type.strip().lower()
+    if wanted and slot_type:
+        is_high_flow = slot_type.upper().startswith("HH")
+        if wanted.startswith("high flow") != is_high_flow:
+            return False
+    return True
+
+
+# The nozzle currently picked up onto the rack carriage. Physical id 1 is the
+# fixed hotend (``_FIXED_NOZZLE_ID``), so the other carriage entry is 0.
+_RACK_CARRIAGE_NOZZLE_ID = 0
+
+
+def _rack_by_position(rack_slots: list[dict]) -> dict[int, dict]:
+    """Live rack contents keyed by 1-based position, mounted nozzle included.
+
+    The firmware omits a rack id entirely while that nozzle is picked up onto
+    the carriage (#943) -- it does not send an empty placeholder. Taking the
+    omission at face value would rule the nozzle ineligible for the very print
+    that wants it, and it is the single most likely position to be picked,
+    because it is the one the last print left mounted.
+
+    The absent id is recoverable only when exactly one is missing: rack ids are
+    fixed at 16..21, so a single gap alongside a loaded carriage is that
+    carriage's nozzle. Two or more gaps are genuinely ambiguous -- an operator
+    with four nozzles in six positions looks the same -- so those stay absent
+    and the caller treats them as empty.
+
+    Measured 2026-08-14 09:02 on the maintainer's H2C: ``IDs: [16, 1, 21, 19,
+    18, 0, 20]`` -- both carriages present, rack id 17 the lone gap.
+    """
+    by_position: dict[int, dict] = {}
+    carriage: dict | None = None
+    for slot in rack_slots or []:
+        if not isinstance(slot, dict) or not isinstance(slot.get("id"), int):
+            continue
+        if slot["id"] == _RACK_CARRIAGE_NOZZLE_ID:
+            carriage = slot
+            continue
+        position = slot["id"] - _RACK_POSITION_BASE
+        if position in RACK_POSITIONS:
+            by_position[position] = slot
+
+    missing = [position for position in RACK_POSITIONS if position not in by_position]
+    if len(missing) == 1 and carriage is not None and (carriage.get("diameter") or carriage.get("type")):
+        by_position[missing[0]] = carriage
+    return by_position
+
+
+def resolve_rack_plan_mapping(
+    slot_groups: list[int],
+    groups: dict[int, dict],
+    choice: dict[int, int],
+    rack_slots: list[dict],
+) -> tuple[list[int] | None, str | None]:
+    """Build a physical ``nozzle_mapping`` from a rack plan and a position pick.
+
+    This is the multi-hotend counterpart to :func:`resolve_rack_nozzle_mapping`.
+    That one can only name the single live rack position, so a plate wanting a
+    different hotend per group is unresolvable to it. Here each group carries
+    its own position, which is the operator's choice (#1784) -- the 3MF states
+    it nowhere, proven by dispatching one plate twice with different picks and
+    diffing the two files down to float noise.
+
+    ``choice`` may be partial or empty; groups it does not name are assigned
+    from the live rack, preferring a position already loaded with the group's
+    own filament colour and otherwise taking the lowest eligible one.
+
+    Returns ``(wire, None)`` on success, or ``(None, reason)`` where *reason*
+    is a sentence naming what could not be satisfied. The caller decides what
+    to do with a failure, and the two cases differ: a stale *explicit* pick
+    should stop the print, while a failed auto-assignment should degrade to
+    letting the firmware choose, exactly as before this existed.
+    """
+    if not isinstance(slot_groups, list) or not slot_groups:
+        return None, "the plate lists no filament slots"
+    if len(slot_groups) > _RACK_WIRE_SLOTS:
+        return None, f"the plate needs {len(slot_groups)} filament slots and the printer takes {_RACK_WIRE_SLOTS}"
+
+    by_position = _rack_by_position(rack_slots)
+
+    # Assign every rack-bound group a position before building the wire, so a
+    # group can never be handed one an earlier group already took. Explicit
+    # picks are placed first: an auto-assignment must yield to them rather than
+    # claim a position the operator asked for.
+    assigned: dict[int, int] = {}
+    rack_group_ids = sorted(gid for gid, g in groups.items() if g.get("on_rack"))
+
+    for group_id in rack_group_ids:
+        position = choice.get(group_id)
+        if position is None:
+            continue
+        group = groups[group_id]
+        if rack_position_to_nozzle_id(position) is None:
+            return None, f"rack position {position} does not exist"
+        if position in assigned.values():
+            return None, f"rack position {position} is picked for more than one filament group"
+        slot = by_position.get(position)
+        if slot is None:
+            return None, f"the printer reports nothing at rack position {position}"
+        if not _rack_slot_is_eligible(slot, group.get("nozzle_diameter", ""), group.get("volume_type", "")):
+            return None, (
+                f"rack position {position} holds a "
+                f"{slot.get('diameter') or 'missing'} {slot.get('type') or ''} nozzle, "
+                f"and the plate needs {group.get('nozzle_diameter')} {group.get('volume_type')}".replace("  ", " ")
+            )
+        assigned[group_id] = position
+
+    for group_id in rack_group_ids:
+        if group_id in assigned:
+            continue
+        group = groups[group_id]
+        eligible = [
+            position
+            for position in RACK_POSITIONS
+            if position not in assigned.values()
+            and position in by_position
+            and _rack_slot_is_eligible(
+                by_position[position], group.get("nozzle_diameter", ""), group.get("volume_type", "")
+            )
+        ]
+        if not eligible:
+            return None, (
+                f"no free rack position holds a {group.get('nozzle_diameter')} "
+                f"{group.get('volume_type')} nozzle for filament group {group_id}"
+            )
+        # Prefer a position already carrying this group's colour: picking it
+        # means the operator does not have to move filament to make the print
+        # match what they asked for.
+        wanted_colour = str(group.get("filament_color") or "").strip().lstrip("#").upper()[:6]
+        assigned[group_id] = next(
+            (
+                position
+                for position in eligible
+                if wanted_colour
+                and str(by_position[position].get("filament_color") or "").strip().lstrip("#").upper()[:6]
+                == wanted_colour
+            ),
+            eligible[0],
+        )
+
+    wire = [-1] * _RACK_WIRE_SLOTS
+    for index, group_id in enumerate(slot_groups):
+        if not isinstance(group_id, int) or isinstance(group_id, bool) or group_id < 0:
+            continue  # slot this plate does not print
+        group = groups.get(group_id)
+        if group is None:
+            return None, f"filament slot {index + 1} names group {group_id}, which the plate does not describe"
+        if not group.get("on_rack"):
+            wire[index] = _FIXED_NOZZLE_ID
+            continue
+        nozzle_id = rack_position_to_nozzle_id(assigned[group_id])
+        if nozzle_id is None:  # pragma: no cover - assigned only ever holds valid positions
+            return None, f"filament group {group_id} resolved to no rack position"
+        wire[index] = nozzle_id
+
+    if all(value == -1 for value in wire):
+        return None, "the plate assigns no filament to a nozzle"
+    return wire, None
+
+
 @dataclass
 class MQTTLogEntry:
     """Log entry for MQTT message debugging."""
@@ -406,7 +760,34 @@ class PrinterState:
     hms_errors: list = field(default_factory=list)  # List of HMSError
     kprofiles: list = field(default_factory=list)  # List of KProfile
     sdcard: bool = False  # SD card inserted
+    # Whether the printer has ever actually told us about `sdcard`. Without this
+    # the default False is indistinguishable from a real "no card", and any
+    # consumer that treats False as evidence would act on silence — which is how
+    # a storage gate turns into a regression for every printer whose firmware
+    # simply doesn't publish the field (#2780).
+    sdcard_reported: bool = False
     store_to_sdcard: bool = False  # Store sent files on SD card (home_flag bit 11)
+    # Scheme+path of a `project_file` dispatch seen on the request topic, from
+    # whoever sent it (the slicer or us). Bambu states where the sliced file
+    # went: `ftp://<name>` is external storage, which FTPS serves, while
+    # `brtc://emmc/<name>` is the printer's internal storage, which it does not.
+    #
+    # Two fields, because the two readers need different guarantees.
+    # ``current_project_url`` belongs to the print now running and is cleared
+    # when that print ends, so a print Bambuddy saw no dispatch for reads as
+    # "unknown" rather than inheriting the previous job's answer. That matters:
+    # 18% of the print starts in #2780's bundle had no dispatch on the request
+    # topic at all (touchscreen reprints, restart recovery), and a stale
+    # internal-storage URL would make those skip an FTPS sweep that could have
+    # found the file — losing an archive that works today.
+    #
+    # ``last_project_url`` is sticky and exists for reporting only: the
+    # connection diagnostic is usually run *after* the print that prompted it,
+    # by which point the per-print value is rightly gone.
+    #
+    # None means we never saw a dispatch — say nothing, don't guess.
+    current_project_url: str | None = None
+    last_project_url: str | None = None
     timelapse: bool = False  # Timelapse recording active
     ipcam: bool = False  # Live view / camera streaming enabled
     wifi_signal: int | None = None  # WiFi signal strength in dBm
@@ -474,6 +855,14 @@ class PrinterState:
     h2d_extruder_snow: dict = field(default_factory=dict)
     # H2C nozzle rack: full device.nozzle.info array for tool-changer printers (>2 nozzles)
     nozzle_rack: list = field(default_factory=list)
+    # H2C rack position currently mounted / being moved to, from
+    # device.nozzle.src_id / tar_id. These are PHYSICAL nozzle IDs (16-21 for
+    # the six rack slots), not extruder indices, and they are what the
+    # dispatch `nozzle_mapping` array has to carry (#2800). Only the printer
+    # can tell us which hotend is in the carriage right now, so this is read
+    # live rather than derived from the queued job.
+    nozzle_rack_src_id: int | None = None
+    nozzle_rack_tar_id: int | None = None
     # Timestamp of last AMS data update (for RFID refresh detection)
     last_ams_update: float = 0.0
     # Printable objects for skip object functionality: {identify_id: object_name}
@@ -591,7 +980,15 @@ STAGE_NAMES = {
 
 def get_stage_name(stage: int) -> str:
     """Get human-readable stage name from stage number."""
-    return STAGE_NAMES.get(stage, f"Unknown stage ({stage})")
+    try:
+        return STAGE_NAMES.get(stage, f"Unknown stage ({stage})")
+    except TypeError:
+        # `stage` is an int by convention only -- it comes straight out of the
+        # printer's JSON, and an unhashable value there would otherwise raise
+        # from inside the f-string that builds the stage-change log line, which
+        # is evaluated on every transition whatever the log level is set to.
+        # Labelling a value must not be able to abort the state update.
+        return f"Unknown stage ({stage})"
 
 
 # #2547 end-of-print telemetry probe.
@@ -680,6 +1077,7 @@ class BambuMQTTClient:
         on_print_running_observed: Callable[[dict], None] | None = None,
         on_finish_photo_moment: Callable[[dict], None] | None = None,
         on_assignment_verified: Callable[[int, int, bool, dict], None] | None = None,
+        on_tray_change: Callable[[int, int], None] | None = None,
     ):
         self.ip_address = ip_address
         self.serial_number = serial_number
@@ -712,6 +1110,12 @@ class BambuMQTTClient:
         # the same shape as on_print_start (filename / subtask_name /
         # remaining_time / raw_data / ams_mapping).
         self.on_print_running_observed = on_print_running_observed
+        # Fired for every entry appended to ``state.tray_change_log`` so main.py
+        # can mirror it into ``active_print_sessions``. The in-memory log dies
+        # with the process, and a long print outliving a restart would
+        # otherwise lose the segment boundaries the usage tracker splits on.
+        # Receives (global_tray_id, layer_num).
+        self.on_tray_change = on_tray_change
         # #1721: fired the moment the printer enters the end-of-print
         # "Filament unloading" phase (stg_cur=22 while progress>=99 or
         # we've hit the last layer / remaining_time<=0). This is the
@@ -744,6 +1148,14 @@ class BambuMQTTClient:
         # — only the dry_time countdown — so we cache what we sent to drive
         # the UI badge. Cleared on stop or on the dry_time falling edge to 0.
         self._drying_targets: dict[int, dict[str, object]] = {}
+        # AMS ids we have sent a stop for and not yet seen end. A stop always
+        # ends a cycle far short of its duration, which on the telemetry alone
+        # is indistinguishable from the firmware abandoning it — so the cycle-end
+        # log would otherwise blame the printer for our own decision (#2770).
+        self._drying_stops_sent: set[int] = set()
+        # Stage numbers this printer has reported that STAGE_NAMES has no entry
+        # for, so each is reported once rather than on every transition into it.
+        self._unnamed_stages_seen: set[int] = set()
 
         self.state = PrinterState()
         self._client: mqtt.Client | None = None
@@ -1343,6 +1755,25 @@ class BambuMQTTClient:
 
             # Intercept request-topic messages (print commands from slicer/Bambuddy)
             if msg.topic == self.topic_publish:
+                # Record it before returning. This topic carries every command
+                # travelling *to* the printer, including the ones Bambu Studio
+                # sends, and it used to be the one thing an MQTT capture could
+                # never show -- which is why "what does Studio put in the drying
+                # command?" had no answer from a user's log (#2774). Filed as
+                # "out" so the direction filter groups it with our own commands
+                # rather than with printer telemetry; anything sent through
+                # send_command lands twice, once on publish and once on the
+                # broker's echo, and the pair is itself evidence the command
+                # reached the broker.
+                if self._logging_enabled:
+                    self._message_log.append(
+                        MQTTLogEntry(
+                            timestamp=datetime.now(timezone.utc).isoformat(),
+                            topic=msg.topic,
+                            direction="out",
+                            payload=payload,
+                        )
+                    )
                 self._handle_request_message(payload)
                 return
 
@@ -1377,6 +1808,14 @@ class BambuMQTTClient:
             return
         command = print_data.get("command", "")
         if command == "project_file":
+            # Where the dispatcher put the sliced file. Captured for every
+            # project_file, ours included: we publish to this same topic and
+            # subscribe to it, so whoever dispatched last wins, which is exactly
+            # the print the archive lookup is about to go looking for (#2780).
+            url = print_data.get("url")
+            if isinstance(url, str) and url:
+                self.state.current_project_url = url
+                self.state.last_project_url = url
             if "ams_mapping" in print_data:
                 self._captured_ams_mapping = print_data["ams_mapping"]
                 logger.info(
@@ -1642,6 +2081,44 @@ class BambuMQTTClient:
                         self._pending_cali_acks[ack_seq] = print_data
                 elif cmd in ("extrusion_cali_sel", "ams_filament_setting"):
                     logger.debug("[%s] %s response: %s", self.serial_number, cmd, print_data)
+                    # A refused ams_filament_setting is the printer's verdict on
+                    # a write the user just made, and at DEBUG it never reached
+                    # a support bundle: #2756 reported six manual Configure Slot
+                    # attempts on an X1C, each returning HTTP 200 with the
+                    # read-back still showing the previous profile, and no
+                    # record of what the printer said about any of them. Same
+                    # promotion as extrusion_cali_set (#2718) and
+                    # ams_filament_drying (#1447) — but only on a non-success,
+                    # because unlike those two this command is not rare: every
+                    # spool assignment and every K-profile re-apply sends one,
+                    # so promoting each ack would bury the interesting line.
+                    #
+                    # The developer-mode probe is excluded. It sends this exact
+                    # command to the external slot precisely to see it refused
+                    # on P1 firmware, so its failure is a normal reading rather
+                    # than a fault. Its response is still matched below (this
+                    # runs before _handle_dev_mode_probe_response clears the
+                    # seq), and user-initiated commands can't be mistaken for
+                    # it — they publish a hardcoded sequence_id of "0".
+                    result = print_data.get("result")
+                    is_dev_mode_probe = (
+                        self._dev_mode_probe_seq is not None
+                        and print_data.get("sequence_id") == self._dev_mode_probe_seq
+                    )
+                    if (
+                        cmd == "ams_filament_setting"
+                        and not is_dev_mode_probe
+                        and isinstance(result, str)
+                        and result.lower() != "success"
+                    ):
+                        logger.info(
+                            "[%s] ams_filament_setting refused: result=%s reason=%s ams_id=%s tray_id=%s",
+                            self.serial_number,
+                            result,
+                            print_data.get("reason", ""),
+                            print_data.get("ams_id"),
+                            print_data.get("tray_id"),
+                        )
                 # AMS drying responses are rare (user-initiated only) and the
                 # full payload — including `result` and any `reason` code —
                 # is the only way to diagnose silent rejections like #1447.
@@ -2475,6 +2952,8 @@ class BambuMQTTClient:
                             tn,
                             self.state.layer_num,
                         )
+                        if self.on_tray_change:
+                            self.on_tray_change(tn, self.state.layer_num)
                     self.state.last_loaded_tray = self.state.tray_now
 
                 self._debug_on_change(
@@ -2751,16 +3230,31 @@ class BambuMQTTClient:
                 current = int(raw_dry_time)
             except (TypeError, ValueError):
                 continue
-            previous = self._previous_dry_times.get(ams_id, 0)
-            self._previous_dry_times[ams_id] = current
-            if previous > 0 and current == 0:
-                logger.info(
-                    "[%s] AMS %d drying complete (dry_time %d → 0)",
+            # A dry_time of 0 only means "finished" when the unit also reports
+            # an idle phase. Between the command ack and the countdown settling
+            # the firmware publishes a transient 0 while the AMS is still
+            # Checking — #2759 caught a 720 → 0 → 719 sequence one minute into a
+            # 12-hour cycle. Taking that at face value dropped the cached target
+            # (leaving the badge to guess the filament from tray 1, so a PLA
+            # cycle read "PETG @ 65°C") and fired on_drying_complete, which
+            # schedules smart-plug auto-off. dry_status comes from the same info
+            # hex parsed above; when it is absent we let the edge through, so a
+            # firmware that never reports one still ends its cycles.
+            if current == 0 and ams_unit.get("dry_status") in _ACTIVE_DRY_STATUSES:
+                # Leave the remembered value alone, exactly as the absent-
+                # dry_time skip above does: whichever push ends the cycle for
+                # real must still see a non-zero previous.
+                logger.debug(
+                    "[%s] AMS %d reported dry_time 0 in phase %s — cycle still live, ignoring",
                     self.serial_number,
                     ams_id,
-                    previous,
+                    ams_unit.get("dry_status"),
                 )
-                self._drying_targets.pop(ams_id, None)
+                continue
+            previous = self._previous_dry_times.get(ams_id, 0)
+            self._previous_dry_times[ams_id] = current
+            if previous > 0 and current == 0:
+                self._log_drying_cycle_end(ams_id, previous, ams_unit, self._drying_targets.pop(ams_id, None))
                 if self.on_drying_complete:
                     self.on_drying_complete(ams_id)
 
@@ -2800,6 +3294,83 @@ class BambuMQTTClient:
         if self._pending_assignments:
             self._check_assignment_verifications()
 
+    def _log_drying_cycle_end(
+        self,
+        ams_id: int,
+        remaining: int,
+        ams_unit: dict,
+        target: dict[str, object] | None,
+    ) -> None:
+        """Report a finished drying cycle, with the firmware's reason when it was
+        cut short (#2770).
+
+        A cycle that reaches its configured duration needs no explanation and
+        keeps the one-line "drying complete" it has always had. One that ends
+        with most of its countdown left was ended by somebody, and there are
+        only two candidates: a stop Bambuddy sent — the print-takes-priority
+        stop, or the user's Stop button — which is named as such, or the
+        firmware.
+
+        For the firmware case the only account of why lives in fields we already
+        parse but have never written down: the ``dry_status`` /
+        ``dry_sub_status`` phase from the info hex, the per-unit
+        ``dry_sf_reason`` constraint codes, and whatever HMS errors are live at
+        that moment. Logging them at INFO puts them in every support bundle by
+        default, which is what a report like #2770 needs before its cause can be
+        argued about at all.
+
+        The unit's ``temp`` and ``humidity_raw`` at the moment of the end are
+        logged for every cycle, early or not, because they are what decides
+        whether auto-drying re-arms. Reconstructing them for #2770 meant
+        cross-referencing hourly alarm lines against 30-second scheduler debug
+        that was switched off at the time; one line here says it outright — a
+        cycle ending at 63 degC with the reading still above the threshold is
+        the whole shape of the re-arm loop.
+        """
+        box = f"temp={ams_unit.get('temp')} humidity={ams_unit.get('humidity_raw', ams_unit.get('humidity'))}"
+        if ams_id in self._drying_stops_sent:
+            self._drying_stops_sent.discard(ams_id)
+            logger.info(
+                "[%s] AMS %d drying stopped by Bambuddy (dry_time %d → 0, %s)",
+                self.serial_number,
+                ams_id,
+                remaining,
+                box,
+            )
+            return
+
+        if remaining <= _EARLY_DRY_END_MINUTES:
+            logger.info(
+                "[%s] AMS %d drying complete (dry_time %d → 0, %s)",
+                self.serial_number,
+                ams_id,
+                remaining,
+                box,
+            )
+            return
+
+        requested_minutes: int | None = None
+        if target is not None:
+            try:
+                requested_minutes = int(target.get("duration_hours") or 0) * 60 or None
+            except (TypeError, ValueError):
+                requested_minutes = None
+
+        logger.info(
+            "[%s] AMS %d drying ended early — %d of %s minutes still on the clock. "
+            "Bambuddy sent no stop command, so the firmware ended this cycle: "
+            "dry_status=%s dry_sub_status=%s dry_sf_reason=%s hms=%s %s",
+            self.serial_number,
+            ams_id,
+            remaining,
+            requested_minutes if requested_minutes is not None else "?",
+            ams_unit.get("dry_status"),
+            ams_unit.get("dry_sub_status"),
+            ams_unit.get("dry_sf_reason") or [],
+            [e.full_code for e in self.state.hms_errors] or "none",
+            box,
+        )
+
     def register_assignment_verification(
         self,
         ams_id: int,
@@ -3049,11 +3620,15 @@ class BambuMQTTClient:
         if "subtask_id" in data:
             self.state.subtask_id = data["subtask_id"]
         if "mc_percent" in data:
-            # Save last non-zero progress for usage tracking (firmware resets to 0 on cancel)
-            if self.state.progress > 0:
-                self._last_valid_progress = self.state.progress
+            # Billing: retain this frame's latest positive value immediately.
+            # A display-side abort may be the very next frame (and may omit
+            # mc_percent entirely), so retaining only the previous frame can
+            # lose the only usable estimate for proportional charging.
             previous_progress = self.state.progress
-            self.state.progress = float(data["mc_percent"])
+            new_progress = float(data["mc_percent"])
+            if new_progress > 0:
+                self._last_valid_progress = new_progress
+            self.state.progress = new_progress
             # #2547: strictly-increasing only. The firmware resets progress to 0
             # on cancel and re-reports the same percent on most frames; neither
             # is the print advancing, and both would make the frame bank grab a
@@ -3204,6 +3779,39 @@ class BambuMQTTClient:
                 logger.debug(
                     f"[{self.serial_number}] stg_cur changed: {prev_stg} -> {new_stg} ({get_stage_name(new_stg)})"
                 )
+                # A stage we cannot name is the one worth seeing at the default
+                # log level: the DEBUG line above is off in normal running, so
+                # an unnamed stage otherwise reaches the user as "Unknown stage
+                # (72)" on a card with nothing behind it to say when it
+                # happened or what the printer was doing. Recorded once per
+                # stage number per session, with the stage it came from and the
+                # print state, which is what naming it later needs. Guarded on
+                # the int type because the field is whatever the firmware sent.
+                if (
+                    isinstance(new_stg, int)
+                    and not isinstance(new_stg, bool)
+                    # -1 is Bambuddy's own "not in a stage" sentinel and the
+                    # initial value of the field, not something the firmware
+                    # reports; every print would otherwise report it on the way
+                    # out of its last real stage.
+                    and new_stg != -1
+                    and new_stg not in STAGE_NAMES
+                    and new_stg not in self._unnamed_stages_seen
+                ):
+                    self._unnamed_stages_seen.add(new_stg)
+                    logger.info(
+                        "[%s] Unnamed print stage %s on model %s, entered from %s (%s); "
+                        "state=%s progress=%s%% layer=%s/%s",
+                        self.serial_number,
+                        new_stg,
+                        self.model,
+                        prev_stg,
+                        get_stage_name(prev_stg),
+                        self.state.state,
+                        self.state.progress,
+                        self.state.layer_num,
+                        self.state.total_layers,
+                    )
             self.state.stg_cur = new_stg
             # #1721 end-of-print finish photo trigger.
             # Stage 22 = "Filament unloading" fires at end-of-print AND
@@ -3953,6 +4561,7 @@ class BambuMQTTClient:
                 self.state.sdcard = "HAS_SDCARD" in raw_sdcard.upper() or raw_sdcard.lower() in ("true", "normal", "1")
             else:
                 self.state.sdcard = bool(raw_sdcard)
+            self.state.sdcard_reported = True
 
         if home_flag is not None:
             store_to_sdcard = bool((home_flag >> 11) & 1)
@@ -4111,6 +4720,36 @@ class BambuMQTTClient:
         if "device" in data and isinstance(data["device"], dict):
             device = data["device"]
             nozzle_data = device.get("nozzle", {})
+
+            # H2C rack position (#2800). `tar_id` is where the carriage is
+            # headed, `src_id` where it came from; mid-swap they differ, so
+            # dispatch prefers tar_id and falls back to src_id. Both are
+            # sticky — the field is only pushed when it changes, so an
+            # absent key must leave the last known value alone rather than
+            # reset it to None.
+            if isinstance(nozzle_data, dict):
+                for key, attr in (("src_id", "nozzle_rack_src_id"), ("tar_id", "nozzle_rack_tar_id")):
+                    if key not in nozzle_data:
+                        continue
+                    try:
+                        parsed_id = int(nozzle_data[key])
+                    except (TypeError, ValueError):
+                        continue
+                    if getattr(self.state, attr) != parsed_id:
+                        setattr(self.state, attr, parsed_id)
+                        # DEBUG, not INFO: these move on every tool change, so
+                        # a long multi-material print would otherwise write
+                        # thousands of lines. The dispatch log records both
+                        # values once per print, which is where triage needs
+                        # them. Same reasoning as the one-shot `nozzle_info`
+                        # log below.
+                        logger.debug(
+                            "[%s] Nozzle rack %s -> %s",
+                            self.serial_number,
+                            key,
+                            parsed_id,
+                        )
+
             nozzle_info = nozzle_data.get("info", [])
             if isinstance(nozzle_info, list):
                 # H2 series: nozzle_info contains extended nozzle data (wear, serial,
@@ -4495,6 +5134,11 @@ class BambuMQTTClient:
                 }
             )
             self._captured_ams_mapping = None
+            # Same lifecycle as the mapping above: it described *this* print.
+            # Leaving it set would hand the next print an answer about where a
+            # different file went, and a stale "internal storage" reading costs
+            # an archive that the FTPS sweep would have found (#2780).
+            self.state.current_project_url = None
 
         self._previous_gcode_state = self.state.state
         if current_file:
@@ -4772,6 +5416,7 @@ class BambuMQTTClient:
         use_ams: bool = True,
         nozzle_offset_cali: str = "auto",
         nozzle_mapping: str | None = None,
+        nozzle_slot_extruders: str | None = None,
     ):
         """Start a print job on the printer.
 
@@ -4798,6 +5443,14 @@ class BambuMQTTClient:
                 firmware honours the user's slicer pick instead of falling
                 back to "last matching nozzle" auto-pick. Silently ignored
                 on single-nozzle printers.
+            nozzle_slot_extruders: Opaque JSON string of per-filament-slot
+                MQTT extruder indices, derived from the 3MF when no
+                BambuStudio capture exists (#2800). Consulted only on
+                nozzle-rack models (H2C) and only when `nozzle_mapping` did
+                not already supply one; resolved here into physical rack
+                positions using the live `device.nozzle` state. When it
+                cannot be resolved the field is omitted and the firmware
+                picks, as it did before this existed.
 
         Returns True when the start command was published, False otherwise
         (not connected, or the printer is already busy — see the run-state
@@ -4843,7 +5496,7 @@ class BambuMQTTClient:
             # model name for the brief window after connect before push data
             # arrives. _is_dual_nozzle only ever flips False→True, so it's safe
             # as the primary signal.
-            from backend.app.utils.printer_models import is_dual_nozzle_model
+            from backend.app.utils.printer_models import is_dual_nozzle_model, is_nozzle_rack_model
 
             is_dual_nozzle = self._is_dual_nozzle or is_dual_nozzle_model(self.model)
 
@@ -5053,6 +5706,52 @@ class BambuMQTTClient:
                         nozzle_mapping,
                     )
 
+            # Nozzle-rack fallback (#2800). Only consulted when BambuStudio
+            # never saw the job, so it can never override a real capture. The
+            # queue stores extruder indices per filament slot; the physical
+            # rack position they resolve to is only knowable here, because the
+            # mounted hotend can change between queueing and dispatch.
+            if is_nozzle_rack_model(self.model) and nozzle_slot_extruders and "nozzle_mapping" not in command["print"]:
+                try:
+                    slot_extruders = json.loads(nozzle_slot_extruders)
+                except (json.JSONDecodeError, TypeError):
+                    # TypeError covers a caller handing us the list itself
+                    # rather than its JSON — the field is opaque by contract,
+                    # and a print must not die over the difference.
+                    slot_extruders = None
+                    logger.warning(
+                        "[%s] Invalid nozzle_slot_extruders JSON on dispatch, "
+                        "omitting nozzle_mapping (firmware will auto-pick): %r",
+                        self.serial_number,
+                        nozzle_slot_extruders,
+                    )
+
+                if isinstance(slot_extruders, list):
+                    rack_nozzle_id = (
+                        self.state.nozzle_rack_tar_id
+                        if self.state.nozzle_rack_tar_id in _RACK_NOZZLE_IDS
+                        else self.state.nozzle_rack_src_id
+                    )
+                    resolved = resolve_rack_nozzle_mapping(slot_extruders, rack_nozzle_id)
+                    if resolved is None:
+                        logger.info(
+                            "[%s] Nozzle rack slots %s not resolvable (tar_id=%s src_id=%s); "
+                            "omitting nozzle_mapping so the firmware picks",
+                            self.serial_number,
+                            slot_extruders,
+                            self.state.nozzle_rack_tar_id,
+                            self.state.nozzle_rack_src_id,
+                        )
+                    else:
+                        logger.info(
+                            "[%s] Nozzle rack mapping: slots=%s rack_id=%s -> %s",
+                            self.serial_number,
+                            slot_extruders,
+                            rack_nozzle_id,
+                            resolved,
+                        )
+                        command["print"]["nozzle_mapping"] = resolved
+
             logger.info("[%s] Sending print command: %s", self.serial_number, json.dumps(command))
             self._client.publish(self.topic_publish, json.dumps(command), qos=1)
             # Record what we dispatched so /cover can pick the right plate
@@ -5427,13 +6126,22 @@ class BambuMQTTClient:
         )
         # Track the active-cycle target so the badge can show "PETG @ 65°C"
         # while drying. Bambu only echoes dry_time on subsequent pushes.
+        # duration_hours is not shown anywhere; it is what lets the cycle-end log
+        # say how much of the requested time the firmware actually ran (#2770).
         if mode == 1:
             self._drying_targets[ams_id] = {
                 "filament": filament or "",
                 "temp": int(temp),
+                "duration_hours": int(duration),
             }
+            self._drying_stops_sent.discard(ams_id)
         else:
             self._drying_targets.pop(ams_id, None)
+            # Remember that this cycle's end is ours, so the cycle-end log
+            # attributes it to Bambuddy instead of to the firmware (#2770). A
+            # stop always ends the cycle far short of its duration, which is
+            # otherwise indistinguishable from the firmware abandoning it.
+            self._drying_stops_sent.add(ams_id)
         return True
 
     @staticmethod

+ 158 - 51
backend/app/services/external_camera.py

@@ -9,9 +9,11 @@ to ensure they are well-formed before use.
 
 import asyncio
 import functools
+import ipaddress
 import logging
 import re
 import shutil
+import socket
 from collections.abc import AsyncGenerator, Callable
 from pathlib import Path
 from urllib.parse import urlparse
@@ -22,13 +24,77 @@ from backend.app.core.logging_filters import redact_url_credentials
 
 logger = logging.getLogger(__name__)
 
+# Protocols ffmpeg may use for an RTSP input. RTSP negotiates its media
+# transport at runtime, so the transports have to be here alongside rtsp itself;
+# tls and crypto cover encrypted variants. Everything ffmpeg would otherwise
+# accept behind an -i — file, http, tcp to anywhere, concat — is left out, so a
+# stream that references something outside itself cannot pull it in.
+_RTSP_PROTOCOL_WHITELIST = "rtsp,rtp,udp,tcp,tls,crypto"
+
+
+def _blocked_host_reason(hostname: str) -> str | None:
+    """Describe why *hostname* is a destination we refuse to fetch, or None to allow it.
+
+    Camera URLs are user-supplied and reach the network — over aiohttp for the
+    HTTP types, and as an ``ffmpeg -i`` argument for RTSP — so this is where the
+    SSRF boundary sits. LAN addresses are deliberately allowed: cameras live on
+    the same network as Bambuddy, and blocking RFC-1918 would remove the feature
+    rather than protect it. What is left to refuse is the host talking to
+    itself, the unspecified address, link-local (which is where the cloud
+    metadata endpoint lives), and the metadata hostnames.
+
+    IP literals are classified with ``ipaddress`` rather than compared against a
+    list of spellings, because 127.0.0.1, 127.0.0.2, 2130706433, 0177.0.0.1,
+    127.1 and ::ffff:127.0.0.1 all arrive at loopback and a list of strings only
+    ever catches whichever one someone thought to write down. ``inet_aton``
+    comes first because it accepts the legacy octal, decimal and short forms
+    that ``ip_address`` rejects — the C resolvers behind aiohttp and ffmpeg
+    accept them, so refusing to understand them here would only mean not seeing
+    where the request is actually going.
+    """
+    host = hostname.lower()
+
+    ip: ipaddress.IPv4Address | ipaddress.IPv6Address | None = None
+    try:
+        ip = ipaddress.ip_address(socket.inet_aton(host))
+    except OSError:
+        try:
+            ip = ipaddress.ip_address(host)
+        except ValueError:
+            ip = None
+
+    if ip is None:
+        # A name, not an address. It is not resolved here on purpose: aiohttp
+        # and ffmpeg each resolve independently afterwards, so a check here
+        # decides nothing about where they end up (DNS rebinding), while a
+        # lookup on every capture would break LAN cameras behind slow or
+        # intermittent local DNS.
+        if host == "localhost" or host.endswith(".localhost"):
+            return "localhost"
+        if host in ("metadata.google.internal", "metadata.google"):
+            return "a cloud metadata service"
+        return None
+
+    # ::ffff:127.0.0.1 is loopback wearing an IPv6 spelling.
+    mapped = getattr(ip, "ipv4_mapped", None)
+    if mapped is not None:
+        ip = mapped
+
+    if ip.is_loopback:
+        return "loopback"
+    if ip.is_unspecified:
+        return "the unspecified address"
+    if ip.is_link_local:
+        return "a link-local address (the cloud metadata range)"
+    return None
+
 
 def _sanitize_camera_url(url: str, allowed_schemes: tuple[str, ...] = ("http", "https", "rtsp")) -> str | None:
     """Validate and sanitize camera URL, returning a safe reconstructed URL.
 
-    This validates that the URL is well-formed, uses an allowed scheme,
-    does not target cloud metadata services, and returns a reconstructed
-    URL from validated components.
+    This validates that the URL is well-formed, uses an allowed scheme, does not
+    target the host itself or a cloud metadata service, and returns a URL
+    reconstructed from the validated components.
 
     Note: This intentionally allows user-provided URLs as that is the
     purpose of external camera configuration. Local network IPs are
@@ -51,37 +117,35 @@ def _sanitize_camera_url(url: str, allowed_schemes: tuple[str, ...] = ("http", "
         if scheme not in allowed_schemes:
             return None
 
-        # Block cloud metadata service endpoints (SSRF mitigation)
-        # These are dangerous destinations that should never be accessed
         hostname = parsed.hostname or ""
-        hostname_lower = hostname.lower()
-        blocked_hosts = (
-            "169.254.169.254",  # AWS/GCP/Azure metadata
-            "metadata.google.internal",  # GCP metadata
-            "metadata.google",
-            "localhost",  # Block localhost to prevent internal service access
-            "127.0.0.1",
-            "::1",
-            "0.0.0.0",  # nosec B104
-        )
-        if hostname_lower in blocked_hosts:
-            logger.warning("Blocked camera URL targeting restricted host: %s", hostname)
+        if not hostname:
             return None
-
-        # Block link-local addresses (169.254.x.x)
-        if hostname.startswith("169.254."):
-            logger.warning("Blocked camera URL targeting link-local address: %s", hostname)
+        blocked = _blocked_host_reason(hostname)
+        if blocked:
+            logger.warning("Blocked camera URL targeting %s: %s", blocked, hostname)
             return None
 
         # Reconstruct URL from validated components to break taint chain
         # This creates a new string from validated parts
+        #
+        # The credentials are carried across verbatim from netloc rather than
+        # via parsed.username/.password, which urlparse has already percent-
+        # decoded: re-emitting those would corrupt any password containing an
+        # @ or a :. They have to survive at all because most RTSP cameras — and
+        # a fair number of MJPEG ones — carry their login in the URL, and
+        # dropping it turns every one of them into an authentication failure.
+        netloc = parsed.netloc
+        userinfo = f"{netloc.rsplit('@', 1)[0]}@" if "@" in netloc else ""
+        # parsed.hostname has already stripped the brackets off an IPv6 literal;
+        # without them back the result is not a URL any client can parse.
+        host_str = f"[{hostname}]" if ":" in hostname else hostname
         port_str = f":{parsed.port}" if parsed.port else ""
         path = parsed.path or ""
         query = f"?{parsed.query}" if parsed.query else ""
         fragment = f"#{parsed.fragment}" if parsed.fragment else ""
 
         # Build sanitized URL from validated components
-        sanitized = f"{scheme}://{hostname}{port_str}{path}{query}{fragment}"
+        sanitized = f"{scheme}://{userinfo}{host_str}{port_str}{path}{query}{fragment}"
         return sanitized
     except ValueError:
         return None
@@ -380,18 +444,18 @@ async def _capture_frame_uncoalesced(
         return None
 
 
-async def _capture_usb_frame(device: str, timeout: int) -> bytes | None:
-    """Capture frame from USB camera using ffmpeg."""
-    ffmpeg = get_ffmpeg_path()
-    if not ffmpeg:
-        logger.error("ffmpeg not found - required for USB camera capture")
-        return None
+def _safe_usb_device_path(device: str) -> str | None:
+    """Rebuild a /dev/videoN path from a validated device number, or None.
 
-    # Validate device path - must be /dev/videoN format where N is 0-99
-    # This prevents path traversal by using a strict allowlist approach
-    import re as regex_module
+    Validate device path - must be /dev/videoN format where N is 0-99. This
+    prevents path traversal by using a strict allowlist approach: the returned
+    path is built from an integer, which cannot carry a traversal, rather than
+    from any part of the caller's string.
 
-    device_match = regex_module.match(r"^/dev/video(\d{1,2})$", device)
+    Returns None if the device does not exist, so a caller cannot hand ffmpeg a
+    path to something that is not a device node.
+    """
+    device_match = re.match(r"^/dev/video(\d{1,2})$", device)
     if not device_match:
         logger.error("Invalid USB device path format: %s", device)
         return None
@@ -399,9 +463,6 @@ async def _capture_usb_frame(device: str, timeout: int) -> bytes | None:
     # Convert to integer to break taint chain - integers cannot contain path traversal
     # lgtm[py/path-injection] - device_num is validated integer 0-99
     device_num = int(device_match.group(1))  # Safe: regex guarantees 1-2 digits
-    if device_num > 99:
-        logger.error("USB device number out of range: %s", device_num)
-        return None
 
     # Construct safe path from validated integer (completely untainted)
     safe_device_path = Path(f"/dev/video{device_num}")  # lgtm[py/path-injection]
@@ -410,8 +471,22 @@ async def _capture_usb_frame(device: str, timeout: int) -> bytes | None:
         logger.error("USB device does not exist: %s", safe_device_path)
         return None
 
+    return str(safe_device_path)  # lgtm[py/path-injection]
+
+
+async def _capture_usb_frame(device: str, timeout: int) -> bytes | None:
+    """Capture frame from USB camera using ffmpeg."""
+    ffmpeg = get_ffmpeg_path()
+    if not ffmpeg:
+        logger.error("ffmpeg not found - required for USB camera capture")
+        return None
+
+    safe_device = _safe_usb_device_path(device)
+    if not safe_device:
+        return None
+
     # Use the safe path for ffmpeg - this is a hardcoded /dev/videoN path
-    device = str(safe_device_path)  # lgtm[py/path-injection]
+    device = safe_device  # lgtm[py/path-injection]
 
     # Use ffmpeg to grab a single frame from USB camera
     cmd = [
@@ -542,22 +617,34 @@ async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
     """Capture frame from RTSP using ffmpeg.
 
     For rtsps:// URLs, a local TLS proxy is used to avoid GnuTLS issues.
+
+    Note: this function intentionally connects to user-configured URLs, the same
+    as the MJPEG and snapshot paths. The URL is sanitized and dangerous
+    destinations are blocked before it reaches ffmpeg.
     """
     ffmpeg = get_ffmpeg_path()
     if not ffmpeg:
         logger.error("ffmpeg not found - required for RTSP capture")
         return None
 
+    # ffmpeg's -i accepts every protocol it was built with, so an unchecked URL
+    # here is a request to any host and scheme the caller names, not merely to a
+    # camera. Restricting the scheme to RTSP is what keeps this a camera fetch.
+    safe_url = _sanitize_camera_url(url, ("rtsp", "rtsps"))
+    if not safe_url:
+        logger.error("Invalid RTSP URL: %s...", redact_url_credentials(url)[:50])
+        return None
+
     # If rtsps://, use TLS proxy
     proxy_server = None
-    effective_url = url
-    if url.lower().startswith("rtsps://"):
+    effective_url = safe_url
+    if safe_url.lower().startswith("rtsps://"):
         try:
             from urllib.parse import urlparse
 
             from backend.app.services.camera import create_tls_proxy
 
-            parsed = urlparse(url)
+            parsed = urlparse(safe_url)
             target_port = parsed.port or 322
             proxy_port, proxy_server = await create_tls_proxy(parsed.hostname, target_port)
             userinfo = ""
@@ -566,17 +653,24 @@ async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
                 if parsed.password:
                     userinfo += f":{parsed.password}"
                 userinfo += "@"
+            # Points at loopback deliberately, and is built after the check
+            # above rather than re-checked: the destination that mattered was
+            # the one the caller named, and it has already been vetted.
             effective_url = f"rtsp://{userinfo}127.0.0.1:{proxy_port}{parsed.path}"
             if parsed.query:
                 effective_url += f"?{parsed.query}"
         except Exception as e:
             logger.warning("Failed to create TLS proxy for RTSP capture, falling back: %s", e)
-            effective_url = url
+            effective_url = safe_url
 
     cmd = [
         ffmpeg,
         "-rtsp_transport",
         "tcp",
+        # Belt and braces on the scheme check above: a demuxer that follows a
+        # reference out of the stream cannot leave these protocols either.
+        "-protocol_whitelist",
+        _RTSP_PROTOCOL_WHITELIST,
         "-i",
         effective_url,
         "-frames:v",
@@ -956,6 +1050,11 @@ async def _stream_rtsp(
     For rtsps:// URLs, a local TLS proxy (Python OpenSSL) is used instead
     of relying on ffmpeg's GnuTLS backend, which has compatibility issues
     with some printer firmwares.
+
+    Note: this function intentionally connects to user-configured URLs. The URL
+    is sanitized and dangerous destinations are blocked before it reaches
+    ffmpeg — see ``_capture_rtsp_frame``, which guards the one-shot path the
+    same way.
     """
     ffmpeg = get_ffmpeg_path()
     if not ffmpeg:
@@ -964,16 +1063,21 @@ async def _stream_rtsp(
 
     from backend.app.services.camera import rtsp_socket_timeout_flag
 
+    safe_url = _sanitize_camera_url(url, ("rtsp", "rtsps"))
+    if not safe_url:
+        logger.error("Invalid RTSP stream URL: %s...", redact_url_credentials(url)[:50])
+        return
+
     # If the URL uses rtsps://, set up a TLS proxy so ffmpeg uses plain rtsp://
     proxy_server = None
-    effective_url = url
-    if url.lower().startswith("rtsps://"):
+    effective_url = safe_url
+    if safe_url.lower().startswith("rtsps://"):
         try:
             from urllib.parse import urlparse
 
             from backend.app.services.camera import create_tls_proxy
 
-            parsed = urlparse(url)
+            parsed = urlparse(safe_url)
             target_port = parsed.port or 322
             proxy_port, proxy_server = await create_tls_proxy(parsed.hostname, target_port)
             # Rewrite URL: rtsps://user:pass@host:port/path → rtsp://user:pass@127.0.0.1:proxy/path
@@ -983,12 +1087,14 @@ async def _stream_rtsp(
                 if parsed.password:
                     userinfo += f":{parsed.password}"
                 userinfo += "@"
+            # Loopback by design, and built after the check above rather than
+            # re-checked — see the same rewrite in _capture_rtsp_frame.
             effective_url = f"rtsp://{userinfo}127.0.0.1:{proxy_port}{parsed.path}"
             if parsed.query:
                 effective_url += f"?{parsed.query}"
         except Exception as e:
             logger.warning("Failed to create TLS proxy for RTSP, falling back to direct: %s", e)
-            effective_url = url
+            effective_url = safe_url
 
     cmd = [
         ffmpeg,
@@ -996,6 +1102,8 @@ async def _stream_rtsp(
         "tcp",
         "-rtsp_flags",
         "prefer_tcp",
+        "-protocol_whitelist",
+        _RTSP_PROTOCOL_WHITELIST,
         # Socket I/O timeout name varies by ffmpeg version (#1504); see
         # `rtsp_socket_timeout_flag()` in services.camera.
         f"-{rtsp_socket_timeout_flag()}",
@@ -1109,14 +1217,13 @@ async def _stream_usb(
         logger.error("ffmpeg not found - required for USB camera streaming")
         return
 
-    # Validate device path
-    if not device.startswith("/dev/video"):
-        logger.error("Invalid USB device path: %s", device)
-        return
-
-    if not Path(device).exists():
-        logger.error("USB device does not exist: %s", device)
+    # Same validation as the one-shot path: a prefix check accepted
+    # /dev/video/../../<anything that exists>, which -f v4l2 would then refuse
+    # rather than the check refusing it.
+    safe_device = _safe_usb_device_path(device)
+    if not safe_device:
         return
+    device = safe_device
 
     # ffmpeg command to stream from USB camera (v4l2)
     cmd = [

+ 163 - 61
backend/app/services/filament_deficit.py

@@ -78,12 +78,43 @@ def _global_to_ams_key(global_tray_id: int) -> tuple[int, int]:
     return (global_tray_id // 4, global_tray_id % 4)
 
 
+def _ams_key_to_global(ams_id: int, tray_id: int) -> int:
+    """Inverse of ``_global_to_ams_key``.
+
+    Mirrors the frontend ``getGlobalTrayId``: external / VT slots (``ams_id``
+    255) land at ``254 + tray_id``, AMS-HT units (``ams_id`` >= 128) use the
+    unit id directly, regular AMS slots use ``ams_id * 4 + tray_id``.
+    """
+    if ams_id >= 255:
+        return 254 + tray_id
+    if ams_id >= 128:
+        return ams_id
+    return ams_id * 4 + tray_id
+
+
 def _resolve_source_3mf(item: PrintQueueItem) -> Path | None:
-    """Locate the 3MF file backing this queue item (archive or library)."""
+    """Locate the 3MF file backing this queue item (archive or library).
+
+    ``LibraryFile.file_path`` is stored relative to ``base_dir`` (rows written
+    before that convention hold absolute paths, which is why every reader
+    guards on ``is_absolute``). Resolving a relative one against the process
+    working directory finds nothing, and a source that cannot be found is
+    treated as "nothing to verify" — so this check silently passed every
+    library-backed item, which is every Slicer Pipeline job and everything
+    queued from the Library page (#2779).
+    """
     if item.archive is not None and item.archive.file_path:
         return app_settings.base_dir / item.archive.file_path
     if item.library_file is not None and item.library_file.file_path:
-        return Path(item.library_file.file_path)
+        library_path = Path(item.library_file.file_path)
+        if library_path.is_absolute():
+            return library_path
+        # SEC-PATH-OK: file_path is DB-stored and generated by the Library
+        # ingest (archive/library/files/<uuid>.<ext>), never request input. The
+        # same value already resolves the file for upload in print_queue.py and
+        # print_scheduler.py — this check reads what the printer is about to be
+        # sent, so it must resolve it identically.
+        return app_settings.base_dir / item.library_file.file_path
     return None
 
 
@@ -270,6 +301,121 @@ async def _get_printer_backup_context(
     return backup_on, ams_extruder_map, is_dual
 
 
+@dataclass(frozen=True)
+class SlotMaterial:
+    """One inventory-bound AMS slot: what's in it, how much is left, which side."""
+
+    ams_id: int
+    tray_id: int
+    global_tray_id: int
+    # Opaque grouping key. Two slots pool for AMS Filament Backup only when
+    # their keys AND extruder sides match. Never parse it — the format is an
+    # implementation detail of ``_material_identity_*``.
+    material_key: str
+    remaining_grams: float
+    extruder: int
+
+    def to_dict(self) -> dict:
+        return {
+            "ams_id": self.ams_id,
+            "tray_id": self.tray_id,
+            "global_tray_id": self.global_tray_id,
+            "material_key": self.material_key,
+            "remaining_g": self.remaining_grams,
+            "extruder": self.extruder,
+        }
+
+
+async def build_slot_materials(db: AsyncSession, printer_id: int) -> list[SlotMaterial]:
+    """Every inventory-bound slot on ``printer_id``, with identity and remaining grams.
+
+    Mode-agnostic: internal inventory resolves via ``SpoolAssignment`` joined to
+    ``Spool`` (``label_weight`` minus ``weight_used``), Spoolman mode via
+    ``SpoolmanSlotAssignment`` plus a live ``get_spool`` fetch. Slots whose
+    remaining weight can't be determined — no spool row, zero label weight,
+    Spoolman unreachable — are omitted entirely rather than reported as zero, so
+    a missing binding never manufactures a shortfall.
+
+    This is the pool the AMS-Filament-Backup accounting draws on (#1762), and
+    the same data the PrintModal pre-flight check consumes through
+    ``GET /printers/{id}/inventory-remain``. Both sides share it so the modal's
+    warning and the dispatcher's 409 can't disagree about what backs what up.
+    """
+    _, ams_extruder_map, is_dual = await _get_printer_backup_context(printer_id)
+    materials: list[SlotMaterial] = []
+
+    def _append(ams_id: int, tray_id: int, material_key: str, remaining: float) -> None:
+        materials.append(
+            SlotMaterial(
+                ams_id=ams_id,
+                tray_id=tray_id,
+                global_tray_id=_ams_key_to_global(ams_id, tray_id),
+                material_key=material_key,
+                remaining_grams=remaining,
+                extruder=_extruder_side_for_ams(ams_id, ams_extruder_map, is_dual),
+            )
+        )
+
+    if await _is_spoolman_mode(db):
+        sm_all = await db.execute(select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == printer_id))
+        from backend.app.services.spoolman import (
+            SpoolmanClientError,
+            SpoolmanNotFoundError,
+            get_spoolman_client,
+        )
+
+        try:
+            client = await get_spoolman_client()
+        except Exception:
+            client = None
+        if client is None:
+            return []
+        for sa in sm_all.scalars().all():
+            try:
+                spool_dict = await client.get_spool(sa.spoolman_spool_id)
+            except (SpoolmanNotFoundError, SpoolmanClientError):
+                continue
+            except Exception as e:
+                logger.debug("Spoolman pool fetch failed for spool %s: %s", sa.spoolman_spool_id, e)
+                continue
+            if not spool_dict:
+                continue
+            remaining: float | None = None
+            rw = spool_dict.get("remaining_weight")
+            if isinstance(rw, (int, float)) and rw >= 0:
+                remaining = float(rw)
+            else:
+                used = spool_dict.get("used_weight")
+                total = (spool_dict.get("filament") or {}).get("weight")
+                if isinstance(used, (int, float)) and isinstance(total, (int, float)) and total > 0:
+                    remaining = max(0.0, float(total) - float(used))
+            if remaining is None:
+                continue
+            _append(sa.ams_id, sa.tray_id, _material_identity_spoolman(spool_dict), remaining)
+        return materials
+
+    internal_all = await db.execute(
+        select(SpoolAssignment)
+        .options(selectinload(SpoolAssignment.spool))
+        .where(SpoolAssignment.printer_id == printer_id)
+    )
+    for assignment in internal_all.scalars().all():
+        spool = assignment.spool
+        if spool is None:
+            continue
+        label_weight = float(spool.label_weight or 0)
+        weight_used = float(spool.weight_used or 0)
+        if label_weight <= 0:
+            continue
+        _append(
+            assignment.ams_id,
+            assignment.tray_id,
+            _material_identity_internal(spool),
+            max(0.0, label_weight - weight_used),
+        )
+    return materials
+
+
 async def compute_deficit_for_queue_item(
     db: AsyncSession,
     item: PrintQueueItem,
@@ -316,7 +462,19 @@ async def compute_deficit_for_queue_item(
     item = refreshed.scalar_one_or_none() or item
 
     source_path = _resolve_source_3mf(item)
-    if source_path is None or not source_path.exists():
+    if source_path is None:
+        # No archive and no library file — nothing was ever attached to check.
+        return []
+    if not source_path.exists():
+        # Dispatch is not blocked: the upload that follows needs the same file
+        # and fails within seconds, where wedging the queue here would strand
+        # it. But skipping a safety check must leave a trace — a silent skip is
+        # what hid #2779 for every library-backed item.
+        logger.warning(
+            "Filament check skipped for queue item %s: source 3MF not found at %s",
+            item.id,
+            source_path,
+        )
         return []
 
     requirements = extract_filament_requirements(source_path, item.plate_id)
@@ -469,64 +627,8 @@ async def compute_deficit_for_queue_item(
     pool_by_key: dict[tuple[str, int], float] = defaultdict(float)
     required_by_key: dict[tuple[str, int], float] = defaultdict(float)
 
-    if spoolman_mode:
-        sm_all = await db.execute(
-            select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == item.printer_id)
-        )
-        from backend.app.services.spoolman import (
-            SpoolmanClientError,
-            SpoolmanNotFoundError,
-            get_spoolman_client,
-        )
-
-        try:
-            client = await get_spoolman_client()
-        except Exception:
-            client = None
-        for sa in sm_all.scalars().all():
-            if client is None:
-                break
-            try:
-                spool_dict = await client.get_spool(sa.spoolman_spool_id)
-            except (SpoolmanNotFoundError, SpoolmanClientError):
-                continue
-            except Exception as e:
-                logger.debug("Spoolman pool fetch failed for spool %s: %s", sa.spoolman_spool_id, e)
-                continue
-            if not spool_dict:
-                continue
-            identity = _material_identity_spoolman(spool_dict)
-            rw = spool_dict.get("remaining_weight")
-            r: float | None = None
-            if isinstance(rw, (int, float)) and rw >= 0:
-                r = float(rw)
-            else:
-                used = spool_dict.get("used_weight")
-                total = (spool_dict.get("filament") or {}).get("weight")
-                if isinstance(used, (int, float)) and isinstance(total, (int, float)) and total > 0:
-                    r = max(0.0, float(total) - float(used))
-            if r is None:
-                continue
-            extruder = _extruder_side_for_ams(sa.ams_id, ams_extruder_map, is_dual)
-            pool_by_key[(identity, extruder)] += r
-    else:
-        internal_all = await db.execute(
-            select(SpoolAssignment)
-            .options(selectinload(SpoolAssignment.spool))
-            .where(SpoolAssignment.printer_id == item.printer_id)
-        )
-        for assignment in internal_all.scalars().all():
-            spool = assignment.spool
-            if spool is None:
-                continue
-            label_weight = float(spool.label_weight or 0)
-            weight_used = float(spool.weight_used or 0)
-            if label_weight <= 0:
-                continue
-            r = max(0.0, label_weight - weight_used)
-            identity = _material_identity_internal(spool)
-            extruder = _extruder_side_for_ams(assignment.ams_id, ams_extruder_map, is_dual)
-            pool_by_key[(identity, extruder)] += r
+    for slot in await build_slot_materials(db, item.printer_id):
+        pool_by_key[(slot.material_key, slot.extruder)] += slot.remaining_grams
 
     for row in resolved:
         required_by_key[(row.identity, row.extruder)] += row.required

+ 45 - 2
backend/app/services/filament_requirements.py

@@ -20,7 +20,10 @@ import xml.etree.ElementTree as ET
 import zipfile
 from pathlib import Path
 
-from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf
+from backend.app.utils.threemf_tools import (
+    extract_nozzle_mapping_from_3mf,
+    extract_rack_plan_from_3mf,
+)
 
 logger = logging.getLogger(__name__)
 
@@ -91,10 +94,16 @@ def extract_filament_requirements(file_path: Path, plate_id: int | None = None)
             # Dual-nozzle printers (H2D / X2D) — annotate which extruder each
             # slot is fed into. Empty mapping for single-nozzle printers, in
             # which case we just don't add the key.
-            nozzle_mapping = extract_nozzle_mapping_from_3mf(zf)
+            # Same plate the filaments above were collected from: a multi-plate
+            # file can assign one slot to different extruders per plate, and
+            # annotating slot 2 with plate 3's nozzle is worse than not
+            # annotating it.
+            nozzle_mapping = extract_nozzle_mapping_from_3mf(zf, plate_id=plate_id)
             if nozzle_mapping:
                 for filament in filaments:
                     filament["nozzle_id"] = nozzle_mapping.get(filament["slot_id"])
+
+            annotate_rack_groups(filaments, file_path, plate_id)
     except Exception as e:
         logger.warning("Failed to parse filament requirements from %s: %s", file_path, e)
         return []
@@ -102,6 +111,40 @@ def extract_filament_requirements(file_path: Path, plate_id: int | None = None)
     return filaments
 
 
+def annotate_rack_groups(filaments: list[dict], file_path: Path, plate_id: int | None) -> None:
+    """Tag each filament with its group and that group's hotend needs (#1784).
+
+    `nozzle_id` says which *carriage*, which is all a two-hotend printer needs.
+    An H2C's rack carriage hosts six, so the print dialog also needs the
+    filament *group* — the slicer's logical nozzle — to offer a rack position
+    for it. Groups are the unit of choice, not slots: two slots in one group
+    share a hotend and cannot be pointed at different positions.
+
+    Annotated whenever the file describes a rack, independently of the nozzle
+    mapping, which is deliberately withheld for exactly the multi-rack plates
+    this is most needed for.
+
+    Mutates ``filaments`` in place and returns nothing, so every caller lands
+    on one implementation: the three filament-requirements paths (archive,
+    library and this module's own parser) each build their filament list
+    differently and would otherwise drift.
+    """
+    rack_plan = extract_rack_plan_from_3mf(file_path, plate_id=plate_id)
+    if rack_plan is None:
+        return
+
+    group_dicts = rack_plan.group_dicts()
+    for filament in filaments:
+        index = filament.get("slot_id", 0) - 1
+        if not 0 <= index < len(rack_plan.slot_groups):
+            continue
+        group_id = rack_plan.slot_groups[index]
+        if group_id < 0:
+            continue
+        filament["group_id"] = group_id
+        filament["group"] = group_dicts.get(group_id)
+
+
 def overrides_for_plate(
     overrides: list[dict],
     file_path: Path | None,

+ 69 - 0
backend/app/services/finance_balance.py

@@ -0,0 +1,69 @@
+"""Canonical definition and synchronization of a user's personal balance."""
+
+from sqlalchemy import and_, func, or_, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.finance import CostCenter, UserWallet, WalletTransaction
+
+
+def transaction_affects_personal_balance(
+    user_id: int,
+    cost_center_id: int | None,
+    *,
+    is_private: bool = False,
+    owner_user_id: int | None = None,
+) -> bool:
+    """Apply the canonical definition to already-loaded transaction data."""
+
+    return cost_center_id is None or (is_private and owner_user_id == user_id)
+
+
+def personal_balance_condition(user_id: int):
+    """Return the SQL condition for transactions in a personal wallet.
+
+    Unassigned transactions and transactions assigned to the user's own
+    private cost center are personal. Shared cost centers are not.
+    """
+
+    return or_(
+        WalletTransaction.cost_center_id.is_(None),
+        and_(CostCenter.is_private.is_(True), CostCenter.owner_user_id == user_id),
+    )
+
+
+async def calculate_personal_balance(db: AsyncSession, user_id: int) -> float:
+    result = await db.execute(
+        select(func.coalesce(func.sum(WalletTransaction.amount), 0.0))
+        .select_from(WalletTransaction)
+        .outerjoin(CostCenter, WalletTransaction.cost_center_id == CostCenter.id)
+        .where(
+            WalletTransaction.user_id == user_id,
+            WalletTransaction.is_voided.is_(False),
+            personal_balance_condition(user_id),
+        )
+    )
+    return round(float(result.scalar_one() or 0.0), 2)
+
+
+async def is_personal_transaction(db: AsyncSession, user_id: int, cost_center_id: int | None) -> bool:
+    if cost_center_id is None:
+        return True
+    result = await db.execute(
+        select(CostCenter.is_private, CostCenter.owner_user_id).where(CostCenter.id == cost_center_id)
+    )
+    center = result.one_or_none()
+    if center is None:
+        return False
+    return transaction_affects_personal_balance(
+        user_id,
+        cost_center_id,
+        is_private=bool(center.is_private),
+        owner_user_id=center.owner_user_id,
+    )
+
+
+async def sync_personal_wallet_balance(db: AsyncSession, wallet: UserWallet) -> float:
+    balance = await calculate_personal_balance(db, wallet.user_id)
+    wallet.balance = balance
+    db.add(wallet)
+    return balance

+ 293 - 0
backend/app/services/finance_billing.py

@@ -0,0 +1,293 @@
+import logging
+import uuid
+
+from sqlalchemy import func, select
+from sqlalchemy.exc import IntegrityError, SQLAlchemyError
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.archive import PrintArchive
+from backend.app.models.finance import TransactionType, UserWallet, WalletTransaction
+from backend.app.services.finance_balance import sync_personal_wallet_balance
+from backend.app.services.finance_budget import is_billing_enabled, release_budget_reservation
+
+logger = logging.getLogger(__name__)
+
+
+class BillingRunIdCollisionError(RuntimeError):
+    """A billing idempotency key points at a different physical print run."""
+
+
+async def _get_balance_after_for_transaction(
+    db: AsyncSession,
+    user_id: int,
+    cost_center_id: int | None,
+    amount: float,
+) -> float:
+    """Calculate balance_after for a transaction.
+
+    For cost-center transactions: sum of ALL transactions for that cost center (global).
+    For personal transactions (cost_center_id=None): user's wallet balance (personal).
+
+    Args:
+        user_id: The user making the transaction
+        cost_center_id: The cost center (None for personal)
+        amount: The transaction amount (positive/negative)
+
+    Returns:
+        The balance after this transaction would be applied
+    """
+    try:
+        if cost_center_id is None:
+            # Personal transaction: use user wallet balance
+            wallet = (await db.execute(select(UserWallet).where(UserWallet.user_id == user_id))).scalar_one_or_none()
+            if wallet is None:
+                return float(amount)
+            return float(wallet.balance) + amount
+        else:
+            # Cost-center transaction: sum of ALL transactions for this cost center (global, not per-user)
+            result = await db.execute(
+                select(func.coalesce(func.sum(WalletTransaction.amount), 0.0)).where(
+                    WalletTransaction.cost_center_id == cost_center_id,
+                    WalletTransaction.is_voided.is_(False),
+                )
+            )
+            current_balance = float(result.scalar() or 0.0)
+            return current_balance + amount
+    except SQLAlchemyError as e:
+        logger.error(f"Database error in _get_balance_after_for_transaction: {e}", exc_info=True)
+        raise
+
+
+def _calculate_partial_charge(
+    archive: PrintArchive,
+    base_cost: float,
+    *,
+    filament_usage: tuple[float | None, float | None] | None = None,
+) -> tuple[float, str]:
+    """Calculate proportional charge for partial prints based on filament usage.
+
+    Returns (charge_amount, description_suffix) where:
+    - charge_amount: absolute cost to charge (0 if insufficient data)
+    - description_suffix: reason/details for transaction description
+    """
+    try:
+        # Only apply proportional calculation for non-completed prints
+        if archive.status == "completed":
+            return round(float(base_cost), 2), ""
+
+        if filament_usage is not None:
+            actual_grams, planned_grams = filament_usage
+            filament_used = float(actual_grams or 0.0)
+            filament_planned = float(planned_grams) if planned_grams is not None else None
+        else:
+            # Backwards-compatible fallback for recalculation and callers that
+            # do not have per-run telemetry. At print completion main.py passes
+            # the measured/progress-scaled run usage explicitly: the archive
+            # field is the slicer's planned amount and must not be mistaken for
+            # the amount consumed by an aborted run.
+            filament_used = float(archive.filament_used_grams or 0.0)
+            filament_planned = None
+
+            if archive.extra_data and isinstance(archive.extra_data, dict):
+                filament_planned = archive.extra_data.get("filament_grams_total")
+                if filament_planned is not None:
+                    filament_planned = float(filament_planned)
+
+        # If we don't have reliable planned filament data, do not guess a partial charge.
+        # Charging a failed/aborted print without an estimated baseline can overcharge users.
+        if filament_planned is None or filament_planned <= 0:
+            return 0.0, f"[{archive.status}: insufficient filament data]"
+
+        # Calculate proportional cost
+        filament_ratio = min(1.0, max(0.0, filament_used / filament_planned))  # Clamp to [0, 1]
+        charge = float(base_cost) * filament_ratio
+
+        # Round charges to 2 decimals for consistent persistence
+        charge = round(charge, 2)
+
+        suffix = f"[{archive.status}: {filament_ratio:.1%} filament ({filament_used:.1f}g/{filament_planned:.1f}g)]"
+        return charge, suffix
+    except ValueError as e:
+        logger.error(f"Value error in _calculate_partial_charge: {e}", exc_info=True)
+        raise
+
+
+async def apply_print_charge_for_archive(
+    db: AsyncSession,
+    archive_id: int,
+    *,
+    charged_user_id: int | None = None,
+    cost_center_id: int | None = None,
+    print_queue_id: int | None = None,
+    print_run_id: str | None = None,
+    base_cost_override: float | None = None,
+    filament_usage: tuple[float | None, float | None] | None = None,
+) -> bool:
+    """Apply an idempotent wallet charge for a print archive.
+
+    Charges completed prints at full cost, and partial/failed prints proportionally
+    based on actual filament used vs. planned filament.
+
+    Returns True when a new wallet transaction was created.
+    """
+    try:
+        if not await is_billing_enabled(db):
+            if print_queue_id is not None:
+                await release_budget_reservation(
+                    db, source_type="print_queue", source_id=print_queue_id, status="released"
+                )
+            else:
+                await release_budget_reservation(db, print_archive_id=archive_id, status="released")
+            logger.info("Billing is disabled; skipping print charge for archive ID %s.", archive_id)
+            return False
+
+        archive = (
+            await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id).with_for_update())
+        ).scalar_one_or_none()
+        if archive is None:
+            logger.warning(f"Archive with ID {archive_id} not found.")
+            return False
+
+        effective_run_id = print_run_id or archive.billing_run_id
+        # The archive-level flag is retained only for legacy deleted charges.
+        # A new scheduler dispatch clears it while persisting its new run UUID;
+        # current deletions are represented by a voided transaction instead.
+        if archive.wallet_charge_skipped:
+            logger.info(f"Wallet charge skipped for archive ID {archive_id}.")
+            return False
+
+        # Accept completed, aborted, cancelled, and failed prints
+        if archive.status not in ("completed", "aborted", "cancelled", "failed"):
+            logger.info(f"Archive ID {archive_id} has status {archive.status}, which is not chargeable.")
+            return False
+
+        actual_user_id = charged_user_id if charged_user_id is not None else archive.created_by_id
+        if actual_user_id is None:
+            logger.warning(f"Archive ID {archive_id} has no creator ID.")
+            return False
+
+        base_cost = float(base_cost_override if base_cost_override is not None else (archive.cost or 0.0))
+        if base_cost <= 0:
+            logger.info(f"Base cost for archive ID {archive_id} is zero or negative.")
+            return False
+
+        # New dispatches persist a UUID before sending the printer command.
+        # Generate one here only for legacy/in-flight rows created before that
+        # migration; the locked archive row makes this fallback durable.
+        if not effective_run_id:
+            effective_run_id = str(uuid.uuid4())
+            archive.billing_run_id = effective_run_id
+
+        tx_conditions = [
+            WalletTransaction.transaction_type == TransactionType.PRINT_CHARGE.value,
+            WalletTransaction.print_run_id == effective_run_id,
+        ]
+
+        existing_tx = (await db.execute(select(WalletTransaction).where(*tx_conditions))).scalar_one_or_none()
+        if existing_tx is not None:
+            if existing_tx.print_archive_id != archive.id:
+                logger.critical(
+                    "BILLING RUN ID COLLISION: run %s belongs to archive %s, not archive %s; charge aborted",
+                    effective_run_id,
+                    existing_tx.print_archive_id,
+                    archive.id,
+                )
+                raise BillingRunIdCollisionError(
+                    f"Billing run ID {effective_run_id} is already assigned to another archive"
+                )
+            logger.info(f"Transaction already exists for archive ID {archive_id}.")
+            if existing_tx.is_voided:
+                logger.info("Print charge for run %s was voided by an administrator.", effective_run_id)
+            return False
+
+        # Calculate charge (full for completed, partial for others)
+        charge, reason_suffix = _calculate_partial_charge(
+            archive,
+            base_cost,
+            filament_usage=filament_usage,
+        )
+        if charge <= 0:
+            if print_queue_id is not None:
+                await release_budget_reservation(
+                    db, source_type="print_queue", source_id=print_queue_id, status="released"
+                )
+            else:
+                await release_budget_reservation(db, print_archive_id=archive.id, status="released")
+            logger.info(f"Calculated charge for archive ID {archive_id} is zero or negative.")
+            return False
+
+        actual_cost_center_id = cost_center_id if cost_center_id is not None else archive.cost_center_id
+
+        wallet = (await db.execute(select(UserWallet).where(UserWallet.user_id == actual_user_id))).scalar_one_or_none()
+        if wallet is None:
+            wallet = UserWallet(user_id=actual_user_id, balance=0.0, currency="EUR")
+            db.add(wallet)
+            await db.flush()
+            logger.info("Created new wallet for user ID %s.", actual_user_id)
+
+        label = archive.print_name or archive.filename or f"Archive {archive.id}"
+        description = f"Print charge: {label}{' ' + reason_suffix if reason_suffix else ''}"
+
+        balance_after = await _get_balance_after_for_transaction(db, actual_user_id, actual_cost_center_id, -charge)
+        if balance_after is not None:
+            balance_after = round(float(balance_after), 2)
+
+        tx = WalletTransaction(
+            user_id=actual_user_id,
+            cost_center_id=actual_cost_center_id,
+            transaction_type=TransactionType.PRINT_CHARGE.value,
+            amount=-charge,
+            balance_after=balance_after,
+            description=description,
+            created_by_user_id=None,
+            print_run_id=effective_run_id,
+            print_archive_id=archive.id,
+            print_queue_id=print_queue_id,
+        )
+        # Limit a concurrent deduplication conflict to a savepoint. The caller
+        # owns the outer transaction, which may already contain archive-owner
+        # backfills and other completion updates that must survive this race.
+        try:
+            async with db.begin_nested():
+                db.add(tx)
+                # Flush inside the savepoint to detect unique/index conflicts.
+                await db.flush()
+        except IntegrityError as e:
+            # Distinguish a legitimate concurrent retry of this exact run from
+            # a collision or an unrelated constraint failure. Only the former
+            # is an idempotent no-op; everything else must remain loud so the
+            # caller rolls back and the budget reservation stays active.
+            concurrent_tx = (await db.execute(select(WalletTransaction).where(*tx_conditions))).scalar_one_or_none()
+            if concurrent_tx is not None and concurrent_tx.print_archive_id == archive.id:
+                logger.info("Transaction already exists for archive ID %s (concurrent), skipping", archive_id)
+                return False
+            logger.critical(
+                "Failed to persist billing charge for archive %s and run %s: %s",
+                archive_id,
+                effective_run_id,
+                e,
+                exc_info=True,
+            )
+            if concurrent_tx is not None:
+                raise BillingRunIdCollisionError(
+                    f"Billing run ID {effective_run_id} is already assigned to another archive"
+                ) from e
+            raise
+
+        # Rebuild from the canonical personal-ledger definition. A shared cost
+        # center charge must not debit the user's personal wallet.
+        new_wallet_balance = await sync_personal_wallet_balance(db, wallet)
+
+        # Consume matching budget reservations after the transaction is persisted
+        if print_queue_id is not None:
+            await release_budget_reservation(db, source_type="print_queue", source_id=print_queue_id, status="consumed")
+        else:
+            await release_budget_reservation(db, print_archive_id=archive.id, status="consumed")
+        logger.info(f"Applied print charge for archive ID {archive_id}. New balance: {new_wallet_balance}.")
+        return True
+    except SQLAlchemyError as e:
+        logger.error(f"Database error in apply_print_charge_for_archive: {e}", exc_info=True)
+        raise
+    except ValueError as e:
+        logger.error(f"Value error in apply_print_charge_for_archive: {e}", exc_info=True)
+        return False

+ 298 - 0
backend/app/services/finance_budget.py

@@ -0,0 +1,298 @@
+"""Budget validation helpers for finance-aware print dispatch."""
+
+import calendar
+from datetime import datetime, timezone
+from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
+
+from fastapi import HTTPException
+from sqlalchemy import case, func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.finance import BudgetReservation, CostCenter, CostCenterMember, WalletTransaction
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.settings import Settings
+from backend.app.models.user import User
+
+
+async def is_billing_enabled(db: AsyncSession) -> bool:
+    # Consider any 'billing_enabled' setting with a true-ish value as enabling billing.
+    result = await db.execute(
+        select(func.count())
+        .select_from(Settings)
+        .where(Settings.key == "billing_enabled", func.lower(func.coalesce(Settings.value, "")) == "true")
+    )
+    count = int(result.scalar_one() or 0)
+    return count > 0
+
+
+async def is_printer_kill_switch_enabled(db: AsyncSession) -> bool:
+    """Return True when billing and the printer kill-switch are both enabled."""
+
+    result = await db.execute(
+        select(Settings.key, Settings.value).where(Settings.key.in_(("billing_enabled", "printer_kill_switch_enabled")))
+    )
+    values = {key: (value or "").strip().lower() for key, value in result.all()}
+    return values.get("billing_enabled") == "true" and values.get("printer_kill_switch_enabled") == "true"
+
+
+async def _get_budget_window_start_utc(db: AsyncSession) -> datetime:
+    result = await db.execute(
+        select(Settings).where(Settings.key.in_(["finance_budget_reset_day", "finance_budget_reset_timezone"]))
+    )
+    values = {setting.key: setting.value for setting in result.scalars().all()}
+
+    desired_day = 1
+    try:
+        parsed = int(values.get("finance_budget_reset_day") or 1)
+        if 1 <= parsed <= 31:
+            desired_day = parsed
+    except (TypeError, ValueError):
+        pass
+
+    timezone_name = values.get("finance_budget_reset_timezone") or "UTC"
+    try:
+        tz = ZoneInfo(timezone_name)
+    except ZoneInfoNotFoundError:
+        tz = ZoneInfo("UTC")
+
+    now = datetime.now(tz)
+    current_month_reset_day = min(desired_day, calendar.monthrange(now.year, now.month)[1])
+    if now.day < current_month_reset_day:
+        month = now.month - 1
+        year = now.year
+        if month == 0:
+            month = 12
+            year -= 1
+    else:
+        month = now.month
+        year = now.year
+
+    reset_day = min(desired_day, calendar.monthrange(year, month)[1])
+    return datetime(year, month, reset_day, tzinfo=tz).astimezone(timezone.utc)
+
+
+async def _cost_center_spend(db: AsyncSession, cost_center_id: int, *, monthly: bool) -> float:
+    spend_expr = case((WalletTransaction.amount < 0, -WalletTransaction.amount), else_=0.0)
+    conditions = [
+        WalletTransaction.cost_center_id == cost_center_id,
+        WalletTransaction.cost_center_id.is_not(None),
+        WalletTransaction.is_voided.is_(False),
+    ]
+    if monthly:
+        conditions.append(WalletTransaction.created_at >= await _get_budget_window_start_utc(db))
+
+    result = await db.execute(select(func.coalesce(func.sum(spend_expr), 0.0)).where(*conditions))
+    return float(result.scalar() or 0.0)
+
+
+async def get_cost_center_reserved_map(
+    db: AsyncSession,
+    cost_center_ids: list[int],
+    *,
+    exclude_queue_item_id: int | None = None,
+    exclude_reservation_source_type: str | None = None,
+    exclude_reservation_source_id: int | None = None,
+) -> dict[int, float]:
+    """Return active holds plus unreserved open queue estimates per cost center.
+
+    Queue items that already have an active ``print_queue`` reservation are
+    excluded from the queue sum because the reservation is their replacement,
+    not an additional hold.
+    """
+
+    if not cost_center_ids:
+        return {}
+
+    active_queue_reservation = (
+        select(BudgetReservation.id)
+        .where(
+            BudgetReservation.status == "active",
+            BudgetReservation.source_type == "print_queue",
+            BudgetReservation.source_id == PrintQueueItem.id,
+        )
+        .exists()
+    )
+    queue_conditions = [
+        PrintQueueItem.cost_center_id.in_(cost_center_ids),
+        PrintQueueItem.status.in_(("pending", "printing")),
+        ~active_queue_reservation,
+    ]
+    if exclude_queue_item_id is not None:
+        queue_conditions.append(PrintQueueItem.id != exclude_queue_item_id)
+
+    queue_rows = await db.execute(
+        select(PrintQueueItem.cost_center_id, func.coalesce(func.sum(PrintQueueItem.estimated_cost), 0.0))
+        .where(*queue_conditions)
+        .group_by(PrintQueueItem.cost_center_id)
+    )
+    reserved_map = {int(center_id): float(value) for center_id, value in queue_rows.all() if center_id is not None}
+
+    reservation_conditions = [
+        BudgetReservation.cost_center_id.in_(cost_center_ids),
+        BudgetReservation.status == "active",
+    ]
+    if exclude_reservation_source_type is not None and exclude_reservation_source_id is not None:
+        reservation_conditions.append(
+            ~(
+                (BudgetReservation.source_type == exclude_reservation_source_type)
+                & (BudgetReservation.source_id == exclude_reservation_source_id)
+            )
+        )
+    reservation_rows = await db.execute(
+        select(BudgetReservation.cost_center_id, func.coalesce(func.sum(BudgetReservation.amount), 0.0))
+        .where(*reservation_conditions)
+        .group_by(BudgetReservation.cost_center_id)
+    )
+    for center_id, value in reservation_rows.all():
+        if center_id is not None:
+            reserved_map[int(center_id)] = reserved_map.get(int(center_id), 0.0) + float(value or 0.0)
+    return reserved_map
+
+
+async def validate_print_budget(
+    db: AsyncSession,
+    *,
+    cost_center_id: int | None,
+    estimated_cost: float | None,
+    current_user: User | None,
+    quantity: int = 1,
+    exclude_queue_item_id: int | None = None,
+    exclude_reservation_source_type: str | None = None,
+    exclude_reservation_source_id: int | None = None,
+) -> None:
+    """Validate that a print can be assigned to a cost center budget."""
+    if not await is_billing_enabled(db):
+        return
+
+    if cost_center_id is None:
+        raise HTTPException(status_code=400, detail="Cost center is required when billing is enabled")
+
+    if estimated_cost is None or estimated_cost <= 0:
+        raise HTTPException(status_code=400, detail="Estimated cost is required for cost center prints")
+
+    center = await db.scalar(select(CostCenter).where(CostCenter.id == cost_center_id).with_for_update())
+    if not center:
+        raise HTTPException(status_code=404, detail="Cost center not found")
+    if not center.is_active:
+        raise HTTPException(status_code=400, detail="Cost center is inactive")
+
+    if current_user is not None and not current_user.is_admin:
+        if center.is_private:
+            if center.owner_user_id != current_user.id:
+                raise HTTPException(status_code=403, detail="You cannot print with this private cost center")
+        else:
+            member = await db.scalar(
+                select(CostCenterMember).where(
+                    CostCenterMember.cost_center_id == cost_center_id,
+                    CostCenterMember.user_id == current_user.id,
+                )
+            )
+            if not member or not member.can_print:
+                raise HTTPException(status_code=403, detail="You cannot print with this cost center")
+
+    budget_limit = center.monthly_budget if center.monthly_budget is not None else center.total_budget
+    if budget_limit is None:
+        return
+
+    used = await _cost_center_spend(db, cost_center_id, monthly=center.monthly_budget is not None)
+    reserved_map = await get_cost_center_reserved_map(
+        db,
+        [cost_center_id],
+        exclude_queue_item_id=exclude_queue_item_id,
+        exclude_reservation_source_type=exclude_reservation_source_type,
+        exclude_reservation_source_id=exclude_reservation_source_id,
+    )
+    reserved = reserved_map.get(cost_center_id, 0.0)
+    requested = estimated_cost * max(1, quantity)
+    available = float(budget_limit) - used - reserved
+    if requested > available:
+        raise HTTPException(
+            status_code=400,
+            detail=f"Estimated print cost exceeds available cost center budget ({requested:.2f} > {available:.2f})",
+        )
+
+
+async def create_budget_reservation(
+    db: AsyncSession,
+    *,
+    cost_center_id: int | None,
+    estimated_cost: float | None,
+    current_user: User | None,
+    source_type: str,
+    source_id: int | None,
+    print_archive_id: int | None = None,
+    exclude_queue_item_id: int | None = None,
+) -> BudgetReservation | None:
+    if not await is_billing_enabled(db):
+        return None
+
+    if cost_center_id is None:
+        raise HTTPException(status_code=400, detail="Cost center is required when billing is enabled")
+
+    await validate_print_budget(
+        db,
+        cost_center_id=cost_center_id,
+        estimated_cost=estimated_cost,
+        current_user=current_user,
+        exclude_queue_item_id=exclude_queue_item_id,
+        exclude_reservation_source_type=source_type,
+        exclude_reservation_source_id=source_id,
+    )
+
+    existing = None
+    if source_id is not None:
+        existing = await db.scalar(
+            select(BudgetReservation).where(
+                BudgetReservation.status == "active",
+                BudgetReservation.source_type == source_type,
+                BudgetReservation.source_id == source_id,
+            )
+        )
+    if existing is not None:
+        existing.cost_center_id = cost_center_id
+        existing.amount = float(estimated_cost or 0.0)
+        if print_archive_id is not None:
+            existing.print_archive_id = print_archive_id
+        await db.flush()
+        return existing
+
+    reservation = BudgetReservation(
+        cost_center_id=cost_center_id,
+        amount=float(estimated_cost or 0.0),
+        status="active",
+        source_type=source_type,
+        source_id=source_id,
+        print_archive_id=print_archive_id,
+    )
+    db.add(reservation)
+    await db.flush()
+    return reservation
+
+
+async def release_budget_reservation(
+    db: AsyncSession,
+    *,
+    source_type: str | None = None,
+    source_id: int | None = None,
+    print_archive_id: int | None = None,
+    status: str = "released",
+) -> int:
+    conditions = [BudgetReservation.status == "active"]
+    if print_archive_id is not None:
+        conditions.append(BudgetReservation.print_archive_id == print_archive_id)
+    else:
+        conditions.extend(
+            [
+                BudgetReservation.source_type == source_type,
+                BudgetReservation.source_id == source_id,
+            ]
+        )
+
+    result = await db.execute(select(BudgetReservation).where(*conditions))
+    reservations = result.scalars().all()
+    for reservation in reservations:
+        reservation.status = status
+        reservation.released_at = datetime.now(timezone.utc)
+    if reservations:
+        await db.flush()
+    return len(reservations)

+ 75 - 0
backend/app/services/finance_defaults.py

@@ -0,0 +1,75 @@
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.finance import CostCenter, CostCenterMember, UserWallet
+from backend.app.models.settings import Settings as AppSettingModel
+from backend.app.models.user import User
+from backend.app.schemas.settings import AppSettings as AppSettingsSchema
+
+
+async def ensure_user_finance_defaults(db: AsyncSession, user: User) -> bool:
+    """Ensure wallet and private cost center defaults exist for a user.
+
+    Returns True when database objects were created or changed.
+    """
+    changed = False
+
+    wallet = (await db.execute(select(UserWallet).where(UserWallet.user_id == user.id))).scalar_one_or_none()
+    if wallet is None:
+        # Respect admin-configured currency if present, otherwise fall back to app default
+        default_currency = AppSettingsSchema().currency
+        result = await db.execute(select(AppSettingModel).where(AppSettingModel.key == "currency"))
+        setting = result.scalar_one_or_none()
+        currency = setting.value if setting and setting.value else default_currency
+        db.add(UserWallet(user_id=user.id, balance=0.0, currency=currency))
+        changed = True
+
+    private_center = (
+        (
+            await db.execute(
+                select(CostCenter)
+                .where(
+                    CostCenter.is_private.is_(True),
+                    CostCenter.owner_user_id == user.id,
+                )
+                .order_by(CostCenter.id.asc())
+            )
+        )
+        .scalars()
+        .first()
+    )
+
+    if private_center is None:
+        private_center = CostCenter(
+            name=user.username,
+            is_active=True,
+            is_private=True,
+            owner_user_id=user.id,
+        )
+        db.add(private_center)
+        await db.flush()
+        changed = True
+    else:
+        # A private center is the billing fallback for its owner and therefore
+        # must remain active. A zero budget is the supported way to prevent
+        # printing from it.
+        if not private_center.is_active:
+            private_center.is_active = True
+            changed = True
+        if private_center.name != user.username:
+            private_center.name = user.username
+            changed = True
+
+    membership = (
+        await db.execute(
+            select(CostCenterMember).where(
+                CostCenterMember.cost_center_id == private_center.id,
+                CostCenterMember.user_id == user.id,
+            )
+        )
+    ).scalar_one_or_none()
+    if membership is None:
+        db.add(CostCenterMember(cost_center_id=private_center.id, user_id=user.id, can_print=True))
+        changed = True
+
+    return changed

+ 79 - 0
backend/app/services/git_providers/base.py

@@ -76,3 +76,82 @@ class GitProviderBackend(ABC):
         client: httpx.AsyncClient,
     ) -> dict:
         """Push files to the repository. Returns status/message/commit_sha/files_changed."""
+
+    # --- Read side (restore, issue #2656) ---------------------------------
+    # The backup path only ever writes. Restore needs to walk history, list a
+    # snapshot and read individual blobs back, so these three mirror the
+    # ``{"success": bool, "message": str, ...}`` convention ``test_connection``
+    # already uses rather than raising.
+
+    @abstractmethod
+    async def list_commits(
+        self,
+        repo_url: str,
+        token: str,
+        branch: str,
+        client: httpx.AsyncClient,
+        limit: int = 20,
+    ) -> dict:
+        """List recent commits on ``branch``, newest first.
+
+        Returns ``{"success", "message", "commits": [{"sha", "message", "author", "date"}]}``.
+        """
+
+    @abstractmethod
+    async def get_commit(self, repo_url: str, token: str, ref: str, client: httpx.AsyncClient) -> dict:
+        """Read one commit's display metadata by SHA.
+
+        ``list_commits`` only reaches back as far as its limit, so a ref outside
+        that window has no entry to describe it. This is the direct lookup for
+        that case.
+
+        Returns ``{"success", "message", "commit": {"sha", "message", "author",
+        "date"} | None}``.
+        """
+
+    @abstractmethod
+    async def list_tree(
+        self,
+        repo_url: str,
+        token: str,
+        ref: str,
+        client: httpx.AsyncClient,
+    ) -> dict:
+        """List every blob path present at ``ref``.
+
+        ``ref`` is a concrete commit SHA — the caller resolves "latest" to a SHA
+        via :meth:`list_commits` first, so the snapshot being previewed and the
+        one being restored are provably the same commit even if a scheduled
+        backup lands in between.
+
+        Returns ``{"success", "message", "paths": [str], "blob_shas":
+        {path: sha}}``. ``blob_shas`` is the path -> blob SHA map the listing
+        already had to build, offered so :meth:`fetch_files` need not fetch the
+        same tree again; providers that read files by path return ``{}``.
+        """
+
+    @abstractmethod
+    async def fetch_files(
+        self,
+        repo_url: str,
+        token: str,
+        ref: str,
+        paths: list[str],
+        client: httpx.AsyncClient,
+        blob_shas: dict[str, str] | None = None,
+    ) -> dict:
+        """Read several files' decoded UTF-8 text at ``ref``.
+
+        Batched rather than one-file-at-a-time so providers that need a tree
+        listing to map path -> blob SHA can do that lookup once for the whole
+        restore instead of per file.
+
+        ``blob_shas`` is the map :meth:`list_tree` returned for the same ref, if
+        the caller has one. Passing it saves a second recursive tree GET; a
+        provider that reads by path ignores it, and one that needs it fetches
+        the tree itself when it is absent.
+
+        Returns ``{"success", "message", "files": {path: text}}``. Paths absent
+        from the commit are simply missing from ``files`` — that is not an error,
+        since which categories a given backup contains varies by config.
+        """

+ 33 - 24
backend/app/services/git_providers/forgejo.py

@@ -13,8 +13,9 @@ class ForgejoBackend(GiteaBackend):
     """Backend for Forgejo instances.
 
     Forgejo v15+ returns 404 (not 403) for private repositories when the token
-    lacks repository scope, requiring a /user pre-check to distinguish bad tokens
-    from inaccessible repos. test_connection is overridden to handle this.
+    lacks repository scope, so a bare repo call cannot tell "bad token" from
+    "repo not visible" on its own. test_connection probes /user first to catch
+    the outright-rejected token, then lets the repo call decide everything else.
     Other methods are inherited from GiteaBackend unchanged.
     """
 
@@ -24,37 +25,45 @@ class ForgejoBackend(GiteaBackend):
             api_base = self.get_api_base(repo_url)
             headers = self.get_headers(token)
 
-            # Verify token validity before hitting the repo. On Forgejo v15+,
-            # private repos return 404 (not 403) when the token lacks repo scope,
-            # so we must distinguish "bad token" from "token OK but repo not visible".
+            # Probe /user, but only a 401 here is conclusive: the instance rejects
+            # the token outright, and saying so beats the 404 the repo call may
+            # answer with instead (Forgejo v15+ hides private repos behind 404
+            # rather than 403).
+            #
+            # Every other status falls through to the repo check (#2775). A
+            # repository-scoped token — the kind Forgejo v15 recommends, limited
+            # to one repo — can only carry read/write:issue and
+            # read/write:repository, so /user answers 403 for exactly the tokens
+            # worth encouraging. Treating that as fatal rejected a token that
+            # reaches its own repository perfectly well, which is all a backup
+            # needs: the push path uses the Contents API and the restore path
+            # reads commits, trees and blobs, all under /repos/{owner}/{repo}.
             user_resp = await client.get(f"{api_base}/user", headers=headers)
             if user_resp.status_code == 401:
                 return {"success": False, "message": "Invalid access token", "repo_name": None, "permissions": None}
-            if user_resp.status_code == 403:
-                return {
-                    "success": False,
-                    "message": "Token has no read:user scope; cannot validate identity",
-                    "repo_name": None,
-                    "permissions": None,
-                }
-            if user_resp.status_code != 200:
-                return {
-                    "success": False,
-                    "message": f"Forgejo API error on /user: {user_resp.status_code}",
-                    "repo_name": None,
-                    "permissions": None,
-                }
+            # Whether the token's identity was confirmed. Only used to word the
+            # 404 below — an unconfirmed identity leaves "the token is invalid"
+            # on the list of causes, a confirmed one rules it out.
+            identity_confirmed = user_resp.status_code == 200
 
             repo_resp = await client.get(f"{api_base}/repos/{owner}/{repo}", headers=headers)
 
+            if repo_resp.status_code == 401:
+                return {"success": False, "message": "Invalid access token", "repo_name": None, "permissions": None}
+
             if repo_resp.status_code == 404:
+                message = (
+                    "Repository not found or token cannot access it. "
+                    "On Forgejo v15+, private repositories return 404 (not 403) "
+                    "when the token lacks repository scope. Check that the token has "
+                    "write:repository, and that this repository is one it covers if the "
+                    "token is scoped to specific repositories."
+                )
+                if not identity_confirmed:
+                    message += " The token itself may also be invalid or expired."
                 return {
                     "success": False,
-                    "message": (
-                        "Repository not found or token cannot access it. "
-                        "On Forgejo v15+, private repositories return 404 (not 403) "
-                        "when the token lacks repository scope."
-                    ),
+                    "message": message,
                     "repo_name": None,
                     "permissions": None,
                 }

+ 106 - 0
backend/app/services/git_providers/gitea.py

@@ -12,6 +12,11 @@ from backend.app.services.git_providers.github import GitHubBackend
 
 logger = logging.getLogger(__name__)
 
+# Gitea clamps per_page to MAX_RESPONSE_ITEMS, which defaults to 50. Consulted
+# only when a tree response carries no usable total_count: a page at least this
+# long may be a clamped full page and cannot be assumed to be the last one.
+_ASSUMED_MIN_PAGE_SIZE = 50
+
 
 class GiteaBackend(GitHubBackend):
     """Backend for Gitea instances.
@@ -100,6 +105,107 @@ class GiteaBackend(GitHubBackend):
         headers["Accept"] = "application/json"
         return headers
 
+    async def _blob_shas_at(
+        self,
+        client: httpx.AsyncClient,
+        headers: dict,
+        api_base: str,
+        owner: str,
+        repo: str,
+        ref: str,
+    ) -> tuple[dict[str, str] | None, str]:
+        """Paged override of GitHub's single-GET tree read (#2656).
+
+        Divergence four, alongside the three in the class docstring. GitHub's
+        recursive trees endpoint is not paginated and signals overflow with
+        ``truncated: true``, which the inherited implementation hard-fails on.
+        Gitea and Forgejo *do* page the same endpoint — ``page``/``per_page``,
+        with ``total_count`` alongside the tree — so the inherited version would
+        read only the first page and then report every category beyond it as
+        absent from the commit. A restore that silently skips categories is the
+        exact failure the GitHub version refuses to allow, so this pages instead.
+
+        The cap mirrors GitLab's: reaching it means there are more pages, and
+        that is a failure rather than a partial result. Because the page size is
+        the server's choice rather than ours (see below), the cap is a page count
+        and not a file count.
+        """
+        blobs: dict[str, str] = {}
+        seen = 0
+        page = 1
+        page_size: int | None = None
+        while page <= 50:
+            response = await client.get(
+                f"{api_base}/repos/{owner}/{repo}/git/trees/{ref}",
+                headers=headers,
+                params={"recursive": "true", "page": page, "per_page": 1000},
+            )
+            if response.status_code == 404:
+                return None, f"Commit or tree '{ref}' not found in the repository"
+            if response.status_code != 200:
+                return None, (
+                    f"Failed to list tree (HTTP {response.status_code}): {self._truncated_response_text(response)}"
+                )
+            try:
+                data = response.json()
+            except ValueError:
+                return None, "Non-JSON response listing tree"
+            if not isinstance(data, dict):
+                return None, "Unexpected shape listing tree"
+
+            entries = data.get("tree")
+            if not isinstance(entries, list):
+                entries = []
+            for item in entries:
+                if not isinstance(item, dict) or item.get("type") != "blob":
+                    continue
+                path, sha = item.get("path"), item.get("sha")
+                if isinstance(path, str) and isinstance(sha, str) and path and sha:
+                    blobs[path] = sha
+
+            # total_count counts every entry, trees included, so compare against
+            # what came back rather than against len(blobs).
+            #
+            # Count what the server actually returned, never the per_page we
+            # asked for: Gitea clamps per_page to MAX_RESPONSE_ITEMS, which
+            # defaults to 50. Deriving the offset from the requested 1000 made
+            # page 2 report 1050 entries seen, which clears any total_count below
+            # that — so the loop stopped and returned the first two pages of a
+            # much larger tree as a success. The restore then read every missing
+            # path as "category not present in this commit" and skipped it
+            # silently, the exact failure this override exists to prevent.
+            total = data.get("total_count")
+            seen += len(entries)
+            if page_size is None:
+                page_size = max(len(entries), _ASSUMED_MIN_PAGE_SIZE)
+
+            if not entries:
+                return blobs, ""
+            if isinstance(total, int):
+                if seen >= total:
+                    return blobs, ""
+            elif len(entries) < page_size:
+                # No usable total_count. This used to return here on the *first*
+                # page, i.e. fail open into a success holding whatever one page
+                # happened to be — 50 entries of an arbitrarily large tree under
+                # the default clamp — and the restore then reported every
+                # category beyond it as absent from the commit. Page until a
+                # short or empty page instead; the page-count ceiling below
+                # still gives the correct hard failure for a tree that really is
+                # too large. A page shorter than the first one (or than Gitea's
+                # default clamp, so a genuinely small tree stays one request)
+                # cannot be followed by another. The residual case is an
+                # instance whose MAX_RESPONSE_ITEMS is set *below* 50 and which
+                # also omits total_count; real Gitea and Forgejo always send it
+                # on this route.
+                return blobs, ""
+            page += 1
+
+        return None, (
+            "Repository tree exceeds the listing limit, so the backup contents cannot be "
+            "enumerated reliably. Rotate the backup repository."
+        )
+
     async def push_files(
         self,
         repo_url: str,

+ 246 - 0
backend/app/services/git_providers/github.py

@@ -115,6 +115,252 @@ class GitHubBackend(GitProviderBackend):
                 "is_private": None,
             }
 
+    async def list_commits(
+        self,
+        repo_url: str,
+        token: str,
+        branch: str,
+        client: httpx.AsyncClient,
+        limit: int = 20,
+    ) -> dict:
+        """List recent commits on ``branch`` via the repo commits API."""
+        try:
+            owner, repo = self.parse_repo_url(repo_url)
+            api_base = self.get_api_base(repo_url)
+            headers = self.get_headers(token)
+
+            # GitHub pages with ``per_page`` and ignores ``limit``; Gitea/Forgejo
+            # do the reverse. Sending both lets GiteaBackend inherit this method
+            # unchanged instead of duplicating it for one query parameter.
+            response = await client.get(
+                f"{api_base}/repos/{owner}/{repo}/commits",
+                headers=headers,
+                params={"sha": branch, "per_page": limit, "limit": limit},
+            )
+
+            if response.status_code == 404:
+                return {
+                    "success": False,
+                    "message": (
+                        f"Branch '{branch}' not found, or the repository has no commits yet. "
+                        "Run a backup before restoring."
+                    ),
+                    "commits": [],
+                }
+            if response.status_code != 200:
+                msg = f"Failed to list commits (HTTP {response.status_code}): {self._truncated_response_text(response)}"
+                logger.warning("list_commits %s/%s: %s", owner, repo, msg)
+                return {"success": False, "message": msg, "commits": []}
+
+            try:
+                data = response.json()
+            except ValueError:
+                return {"success": False, "message": "Non-JSON response listing commits", "commits": []}
+            if not isinstance(data, list):
+                return {"success": False, "message": "Unexpected shape listing commits", "commits": []}
+
+            return {"success": True, "message": "OK", "commits": self._parse_commit_entries(data, limit)}
+
+        except Exception as e:
+            logger.exception("list_commits failed for %s branch=%s", repo_url, branch)
+            return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "commits": []}
+
+    @staticmethod
+    def _parse_commit_entries(data: list, limit: int) -> list[dict]:
+        """Normalise GitHub/Gitea commit list entries to our flat shape."""
+        commits = []
+        for entry in data[:limit]:
+            if not isinstance(entry, dict):
+                continue
+            sha = entry.get("sha")
+            if not isinstance(sha, str) or not sha:
+                continue
+            commit = entry.get("commit") if isinstance(entry.get("commit"), dict) else {}
+            author = commit.get("author") if isinstance(commit.get("author"), dict) else {}
+            commits.append(
+                {
+                    "sha": sha,
+                    "message": commit.get("message") or "",
+                    "author": author.get("name") or "",
+                    "date": author.get("date") or "",
+                }
+            )
+        return commits
+
+    async def _blob_shas_at(
+        self,
+        client: httpx.AsyncClient,
+        headers: dict,
+        api_base: str,
+        owner: str,
+        repo: str,
+        ref: str,
+    ) -> tuple[dict[str, str] | None, str]:
+        """Return ``({path: blob_sha}, "")`` at ``ref``, or ``(None, error_message)``.
+
+        A commit SHA is a valid tree-ish for the trees API, so this resolves the
+        commit's tree in one request rather than commit -> tree -> list.
+        """
+        response = await client.get(
+            f"{api_base}/repos/{owner}/{repo}/git/trees/{ref}?recursive=1",
+            headers=headers,
+        )
+        if response.status_code == 404:
+            return None, f"Commit or tree '{ref}' not found in the repository"
+        if response.status_code != 200:
+            return None, f"Failed to list tree (HTTP {response.status_code}): {self._truncated_response_text(response)}"
+        try:
+            data = response.json()
+        except ValueError:
+            return None, "Non-JSON response listing tree"
+        # Same limit the push path guards against: a truncated listing would make
+        # a restore silently skip categories that are actually in the backup.
+        if data.get("truncated"):
+            return None, (
+                "Repository tree exceeds the API listing limit (truncated=true), so the backup "
+                "contents cannot be enumerated reliably. Rotate the backup repository."
+            )
+        blobs: dict[str, str] = {}
+        for item in data.get("tree", []):
+            if not isinstance(item, dict) or item.get("type") != "blob":
+                continue
+            path, sha = item.get("path"), item.get("sha")
+            if isinstance(path, str) and isinstance(sha, str) and path and sha:
+                blobs[path] = sha
+        return blobs, ""
+
+    async def get_commit(self, repo_url: str, token: str, ref: str, client: httpx.AsyncClient) -> dict:
+        """Read one commit's metadata directly, for refs outside the list window."""
+        try:
+            owner, repo = self.parse_repo_url(repo_url)
+            api_base = self.get_api_base(repo_url)
+            headers = self.get_headers(token)
+
+            response = await client.get(f"{api_base}/repos/{owner}/{repo}/commits/{ref}", headers=headers)
+            if response.status_code == 404:
+                return {"success": False, "message": f"Commit '{ref}' not found in the repository", "commit": None}
+            if response.status_code != 200:
+                msg = f"Failed to read commit (HTTP {response.status_code}): {self._truncated_response_text(response)}"
+                logger.warning("get_commit %s/%s ref=%s: %s", owner, repo, ref, msg)
+                return {"success": False, "message": msg, "commit": None}
+
+            try:
+                data = response.json()
+            except ValueError:
+                return {"success": False, "message": "Non-JSON response reading commit", "commit": None}
+            if not isinstance(data, dict):
+                return {"success": False, "message": "Unexpected shape reading commit", "commit": None}
+
+            # Same entry shape as list_commits, so callers can treat the two
+            # interchangeably.
+            parsed = self._parse_commit_entries([data], 1)
+            if not parsed:
+                return {"success": False, "message": "Commit response carried no SHA", "commit": None}
+            return {"success": True, "message": "OK", "commit": parsed[0]}
+
+        except Exception as e:
+            logger.exception("get_commit failed for %s ref=%s", repo_url, ref)
+            return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "commit": None}
+
+    async def list_tree(
+        self,
+        repo_url: str,
+        token: str,
+        ref: str,
+        client: httpx.AsyncClient,
+    ) -> dict:
+        """List blob paths present at ``ref`` via the Git Data trees API."""
+        try:
+            owner, repo = self.parse_repo_url(repo_url)
+            api_base = self.get_api_base(repo_url)
+            headers = self.get_headers(token)
+
+            blobs, error = await self._blob_shas_at(client, headers, api_base, owner, repo, ref)
+            if blobs is None:
+                logger.warning("list_tree %s/%s ref=%s: %s", owner, repo, ref, error)
+                return {"success": False, "message": error, "paths": [], "blob_shas": {}}
+
+            # The map is handed back so fetch_files does not GET the same
+            # recursive tree a second time for the same ref.
+            return {"success": True, "message": "OK", "paths": sorted(blobs), "blob_shas": blobs}
+
+        except Exception as e:
+            logger.exception("list_tree failed for %s ref=%s", repo_url, ref)
+            return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "paths": [], "blob_shas": {}}
+
+    async def fetch_files(
+        self,
+        repo_url: str,
+        token: str,
+        ref: str,
+        paths: list[str],
+        client: httpx.AsyncClient,
+        blob_shas: dict[str, str] | None = None,
+    ) -> dict:
+        """Read ``paths`` at ``ref`` via the Git Data blobs API.
+
+        The blobs API is used rather than the contents API because contents
+        inlines only files up to 1 MB — an archive-heavy ``print_history.json``
+        can exceed that, and it would come back with an empty body instead of an
+        error.
+        """
+        try:
+            owner, repo = self.parse_repo_url(repo_url)
+            api_base = self.get_api_base(repo_url)
+            headers = self.get_headers(token)
+
+            blobs = blob_shas
+            if blobs is None:
+                blobs, error = await self._blob_shas_at(client, headers, api_base, owner, repo, ref)
+                if blobs is None:
+                    logger.warning("fetch_files %s/%s ref=%s: %s", owner, repo, ref, error)
+                    return {"success": False, "message": error, "files": {}}
+
+            files: dict[str, str] = {}
+            for path in paths:
+                sha = blobs.get(path)
+                if sha is None:
+                    continue
+                response = await client.get(f"{api_base}/repos/{owner}/{repo}/git/blobs/{sha}", headers=headers)
+                if response.status_code != 200:
+                    msg = f"Failed to read {path} (HTTP {response.status_code}): {self._truncated_response_text(response)}"
+                    logger.warning("fetch_files %s/%s: %s", owner, repo, msg)
+                    return {"success": False, "message": msg, "files": {}}
+                text, error = self._decode_blob(response, path)
+                if text is None:
+                    logger.warning("fetch_files %s/%s: %s", owner, repo, error)
+                    return {"success": False, "message": error, "files": {}}
+                files[path] = text
+
+            return {"success": True, "message": "OK", "files": files}
+
+        except Exception as e:
+            logger.exception("fetch_files failed for %s ref=%s", repo_url, ref)
+            return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "files": {}}
+
+    def _decode_blob(self, response: httpx.Response, path: str) -> tuple[str | None, str]:
+        """Decode a blob API response body to text, or return an error message."""
+        try:
+            data = response.json()
+        except ValueError:
+            return None, f"Non-JSON response reading {path}"
+        if not isinstance(data, dict):
+            return None, f"Unexpected shape reading {path}"
+        content = data.get("content")
+        if not isinstance(content, str):
+            return None, f"Missing content reading {path}"
+        encoding = data.get("encoding", "base64")
+        try:
+            if encoding == "base64":
+                # Both providers wrap base64 payloads at 60 chars; b64decode
+                # tolerates the newlines, but be explicit about it.
+                return base64.b64decode(content).decode("utf-8"), ""
+            if encoding in ("utf-8", "text", "plain"):
+                return content, ""
+        except (ValueError, UnicodeDecodeError) as e:
+            return None, f"Could not decode {path}: {type(e).__name__}"
+        return None, f"Unsupported blob encoding {encoding!r} reading {path}"
+
     async def push_files(
         self,
         repo_url: str,

+ 261 - 0
backend/app/services/git_providers/gitlab.py

@@ -115,6 +115,267 @@ class GitLabBackend(GitProviderBackend):
                 "is_private": None,
             }
 
+    def _encoded_project(self, repo_url: str) -> str:
+        """Return the URL-encoded ``namespace/project`` path for /api/v4/projects/."""
+        owner, repo = self.parse_repo_url(repo_url)
+        return urllib.parse.quote(f"{owner}/{repo}", safe="")
+
+    async def list_commits(
+        self,
+        repo_url: str,
+        token: str,
+        branch: str,
+        client: httpx.AsyncClient,
+        limit: int = 20,
+    ) -> dict:
+        """List recent commits on ``branch`` via /repository/commits."""
+        try:
+            api_base = self.get_api_base(repo_url)
+            headers = self.get_headers(token)
+            encoded_path = self._encoded_project(repo_url)
+
+            response = await client.get(
+                f"{api_base}/projects/{encoded_path}/repository/commits",
+                headers=headers,
+                params={"ref_name": branch, "per_page": limit},
+            )
+
+            if response.status_code == 404:
+                return {
+                    "success": False,
+                    "message": (
+                        f"Branch '{branch}' not found, or the repository has no commits yet. "
+                        "Run a backup before restoring."
+                    ),
+                    "commits": [],
+                }
+            if response.status_code != 200:
+                msg = f"Failed to list commits (HTTP {response.status_code}): {self._truncated_response_text(response)}"
+                logger.warning("list_commits %s: %s", repo_url, msg)
+                return {"success": False, "message": msg, "commits": []}
+
+            try:
+                data = response.json()
+            except ValueError:
+                return {"success": False, "message": "Non-JSON response listing commits", "commits": []}
+            if not isinstance(data, list):
+                return {"success": False, "message": "Unexpected shape listing commits", "commits": []}
+
+            commits = []
+            for entry in data[:limit]:
+                if not isinstance(entry, dict):
+                    continue
+                sha = entry.get("id")
+                if not isinstance(sha, str) or not sha:
+                    continue
+                # GitLab flattens author/date onto the commit itself rather than
+                # nesting them under "commit" the way GitHub does.
+                commits.append(
+                    {
+                        "sha": sha,
+                        "message": entry.get("message") or "",
+                        "author": entry.get("author_name") or "",
+                        "date": entry.get("committed_date") or entry.get("created_at") or "",
+                    }
+                )
+
+            return {"success": True, "message": "OK", "commits": commits}
+
+        except Exception as e:
+            logger.exception("list_commits failed for %s branch=%s", repo_url, branch)
+            return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "commits": []}
+
+    async def get_commit(self, repo_url: str, token: str, ref: str, client: httpx.AsyncClient) -> dict:
+        """Read one commit's metadata directly, for refs outside the list window."""
+        try:
+            api_base = self.get_api_base(repo_url)
+            headers = self.get_headers(token)
+            encoded_path = self._encoded_project(repo_url)
+
+            response = await client.get(
+                f"{api_base}/projects/{encoded_path}/repository/commits/{urllib.parse.quote(ref, safe='')}",
+                headers=headers,
+            )
+            if response.status_code == 404:
+                return {"success": False, "message": f"Commit '{ref}' not found in the repository", "commit": None}
+            if response.status_code != 200:
+                msg = f"Failed to read commit (HTTP {response.status_code}): {self._truncated_response_text(response)}"
+                logger.warning("get_commit %s ref=%s: %s", repo_url, ref, msg)
+                return {"success": False, "message": msg, "commit": None}
+
+            try:
+                data = response.json()
+            except ValueError:
+                return {"success": False, "message": "Non-JSON response reading commit", "commit": None}
+            sha = data.get("id") if isinstance(data, dict) else None
+            if not isinstance(sha, str) or not sha:
+                return {"success": False, "message": "Commit response carried no SHA", "commit": None}
+
+            # GitLab flattens author/date onto the commit, as in list_commits.
+            return {
+                "success": True,
+                "message": "OK",
+                "commit": {
+                    "sha": sha,
+                    "message": data.get("message") or "",
+                    "author": data.get("author_name") or "",
+                    "date": data.get("committed_date") or data.get("created_at") or "",
+                },
+            }
+
+        except Exception as e:
+            logger.exception("get_commit failed for %s ref=%s", repo_url, ref)
+            return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "commit": None}
+
+    async def list_tree(
+        self,
+        repo_url: str,
+        token: str,
+        ref: str,
+        client: httpx.AsyncClient,
+    ) -> dict:
+        """List blob paths at ``ref`` via /repository/tree, following pagination."""
+        try:
+            api_base = self.get_api_base(repo_url)
+            headers = self.get_headers(token)
+            encoded_path = self._encoded_project(repo_url)
+
+            paths: list[str] = []
+            page = 1
+            complete = False
+            # GitLab's tree endpoint paginates instead of exposing a "truncated"
+            # flag, so walk pages until one comes back short. The page cap stops
+            # a malformed X-Next-Page loop from spinning forever — and reaching
+            # it is a failure, not a result: see the check after the loop.
+            while page <= 50:
+                response = await client.get(
+                    f"{api_base}/projects/{encoded_path}/repository/tree",
+                    headers=headers,
+                    params={"ref": ref, "recursive": "true", "per_page": 100, "page": page},
+                )
+                if response.status_code == 404:
+                    return {
+                        "success": False,
+                        "message": f"Commit or tree '{ref}' not found in the repository",
+                        "paths": [],
+                        "blob_shas": {},
+                    }
+                if response.status_code != 200:
+                    msg = (
+                        f"Failed to list tree (HTTP {response.status_code}): {self._truncated_response_text(response)}"
+                    )
+                    logger.warning("list_tree %s ref=%s: %s", repo_url, ref, msg)
+                    return {"success": False, "message": msg, "paths": [], "blob_shas": {}}
+
+                try:
+                    data = response.json()
+                except ValueError:
+                    return {"success": False, "message": "Non-JSON response listing tree", "paths": [], "blob_shas": {}}
+                if not isinstance(data, list):
+                    return {"success": False, "message": "Unexpected shape listing tree", "paths": [], "blob_shas": {}}
+
+                for item in data:
+                    if isinstance(item, dict) and item.get("type") == "blob":
+                        path = item.get("path")
+                        if isinstance(path, str) and path:
+                            paths.append(path)
+
+                if len(data) < 100:
+                    complete = True
+                    break
+                page += 1
+
+            if not complete:
+                # Falling out of the loop means the last page was full and there
+                # are more. Returning success here would hand the restore a
+                # silently partial path list, and it would then report the
+                # categories it could not see as "not present in this commit" —
+                # the same failure GitHub's truncated=true check refuses to allow.
+                msg = (
+                    "Repository tree exceeds the listing limit (more than 5000 files), so the backup "
+                    "contents cannot be enumerated reliably. Rotate the backup repository."
+                )
+                logger.warning("list_tree %s ref=%s: %s", repo_url, ref, msg)
+                return {"success": False, "message": msg, "paths": [], "blob_shas": {}}
+
+            # GitLab reads files by path, so there is no blob-SHA map to share.
+            return {"success": True, "message": "OK", "paths": sorted(paths), "blob_shas": {}}
+
+        except Exception as e:
+            logger.exception("list_tree failed for %s ref=%s", repo_url, ref)
+            return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "paths": [], "blob_shas": {}}
+
+    async def fetch_files(
+        self,
+        repo_url: str,
+        token: str,
+        ref: str,
+        paths: list[str],
+        client: httpx.AsyncClient,
+        blob_shas: dict[str, str] | None = None,
+    ) -> dict:
+        """Read ``paths`` at ``ref`` via /repository/files/{path}.
+
+        ``blob_shas`` is accepted for interface parity and ignored: this backend
+        addresses files by path, so it never needed the tree listing that makes
+        the map worth passing.
+        """
+        try:
+            api_base = self.get_api_base(repo_url)
+            headers = self.get_headers(token)
+            encoded_path = self._encoded_project(repo_url)
+
+            files: dict[str, str] = {}
+            for path in paths:
+                encoded_file = urllib.parse.quote(path, safe="")
+                response = await client.get(
+                    f"{api_base}/projects/{encoded_path}/repository/files/{encoded_file}",
+                    headers=headers,
+                    params={"ref": ref},
+                )
+                # A path absent from this commit is expected — which categories a
+                # backup contains varies by config — so skip rather than fail.
+                if response.status_code == 404:
+                    continue
+                if response.status_code != 200:
+                    msg = (
+                        f"Failed to read {path} (HTTP {response.status_code}): "
+                        f"{self._truncated_response_text(response)}"
+                    )
+                    logger.warning("fetch_files %s: %s", repo_url, msg)
+                    return {"success": False, "message": msg, "files": {}}
+
+                try:
+                    data = response.json()
+                except ValueError:
+                    return {"success": False, "message": f"Non-JSON response reading {path}", "files": {}}
+                if not isinstance(data, dict):
+                    return {"success": False, "message": f"Unexpected shape reading {path}", "files": {}}
+
+                content = data.get("content")
+                if not isinstance(content, str):
+                    return {"success": False, "message": f"Missing content reading {path}", "files": {}}
+                encoding = data.get("encoding", "base64")
+                try:
+                    if encoding == "base64":
+                        files[path] = base64.b64decode(content).decode("utf-8")
+                    elif encoding in ("text", "utf-8", "plain"):
+                        files[path] = content
+                    else:
+                        return {
+                            "success": False,
+                            "message": f"Unsupported encoding {encoding!r} reading {path}",
+                            "files": {},
+                        }
+                except (ValueError, UnicodeDecodeError) as e:
+                    return {"success": False, "message": f"Could not decode {path}: {type(e).__name__}", "files": {}}
+
+            return {"success": True, "message": "OK", "files": files}
+
+        except Exception as e:
+            logger.exception("fetch_files failed for %s ref=%s", repo_url, ref)
+            return {"success": False, "message": f"{type(e).__name__}: {str(e)[:200]}", "files": {}}
+
     async def push_files(
         self,
         repo_url: str,

+ 50 - 0
backend/app/services/github_backup.py

@@ -173,9 +173,32 @@ class GitHubBackupService:
         Returns:
             dict with success, message, log_id, commit_sha, files_changed
         """
+        # Everything from here to `self._running_backup = True` must stay
+        # await-free. Both flags are plain bools and both callers are coroutines
+        # on one event loop, so with no suspension point in between the loop
+        # cannot run the restore service's mirror-image region (see
+        # github_restore.run_restore) in the gap — whichever gets here first sets
+        # its flag before the other can read it. Adding an `await` inside this
+        # block reintroduces the check-then-set race and lets a backup and a
+        # restore run at once.
         if self._running_backup:
             return {"success": False, "message": "A backup is already running", "log_id": None}
 
+        # Imported locally to avoid a module-level import cycle — the restore
+        # service imports this module's singleton to take the mirror-image lock.
+        # A restore rewrites the same tables this collector reads and publishes
+        # K-profiles to the same printers, so the two must not interleave.
+        # (A local `import` of an already-loaded module is not a suspension
+        # point, so it does not break the await-free rule above.)
+        from backend.app.services.github_restore import github_restore_service
+
+        if github_restore_service.is_running:
+            return {
+                "success": False,
+                "message": "A restore is currently running. Wait for it to finish before backing up.",
+                "log_id": None,
+            }
+
         self._running_backup = True
         log_id = None
 
@@ -805,6 +828,14 @@ class GitHubBackupService:
         if not archives:
             return
 
+        # The natural key for an owner. created_by_id alone is only meaningful on
+        # the instance that wrote it: restoring onto a rebuilt instance — this
+        # feature's main use case — renumbers the users table, so a live id can
+        # land on a different person. username is unique on users, so the restore
+        # can resolve on it and treat a rename as unknown rather than guess.
+        # One query for the map; archives outnumber users by orders of magnitude.
+        user_names = dict((await db.execute(select(User.id, User.username))).all())
+
         archive_list = []
         for a in archives:
             archive_data = {
@@ -840,6 +871,25 @@ class GitHubBackupService:
                 "energy_kwh": a.energy_kwh,
                 "energy_cost": a.energy_cost,
                 "created_at": str(a.created_at) if a.created_at else None,
+                # Soft-deleted archives are collected too — their row is kept on
+                # purpose so the stats endpoint keeps counting their filament and
+                # energy (see archive_service.soft_delete_archive). Recording
+                # deleted_at is what lets a restore put them back the way they
+                # were instead of resurrecting them as visible archives.
+                "deleted_at": str(a.deleted_at) if a.deleted_at else None,
+                # Who owns the archive, for the same reason deleted_at is here:
+                # it is not decoration, it is what the access check runs on.
+                # _ensure_archive_visible (api/routes/archives.py) fails closed on
+                # a NULL created_by_id and the list paths filter on it, so a
+                # restored row without it is invisible to everyone but an admin —
+                # while the restore reports it restored.
+                "created_by_id": a.created_by_id,
+                # Preferred over the id on restore; the id stays as the fallback
+                # for an owner whose row has since gone. Null when the archive
+                # has no owner, or when it points at a user row that no longer
+                # exists locally — the same "absent is not null" rule the restore
+                # applies, so a backup can't claim an owner it cannot name.
+                "created_by_username": user_names.get(a.created_by_id),
             }
             archive_list.append(archive_data)
 

+ 1957 - 0
backend/app/services/github_restore.py

@@ -0,0 +1,1957 @@
+"""Restore Bambuddy data from a Git provider backup (issue #2656).
+
+The backup side (``github_backup.py``) is push-only: it collects a handful of
+JSON documents and commits them. This module is the read side — it walks the
+backup repository's history, lets a caller inspect what a given commit contains,
+and applies selected categories back into the local database (or, for
+K-profiles, back onto the printers).
+
+Design notes worth knowing before editing:
+
+* **A restore never reuses the backup's primary keys.** ``spool.id`` and
+  ``print_archives.id`` are bare autoincrement columns, so the ids in a backup
+  taken weeks ago very likely belong to unrelated rows today. Rows are matched
+  on natural keys instead, inserted without an explicit id, and an
+  ``old_id -> new_id`` map is threaded through so foreign keys in dependent
+  tables (spool usage history) still line up.
+
+  The printer-side ``cali_idx`` behaves the same way and gets the same
+  treatment. Editing a K-profile in Bambuddy is a delete-then-add on a
+  single-nozzle printer, which re-keys it, and ``extrusion_cali_set`` aimed at a
+  slot that no longer exists is silently dropped — so the live index is read
+  back and matched before writing, never taken from the backup.
+* **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 talk to hardware.
+* **Cloud profiles are not restorable.** Restoring a preset means writing to a
+  Bambu or Orca Cloud account, which is a different operation from everything
+  else here — every other category lands in the local database or, for
+  K-profiles, on a printer the instance already owns. Tracked separately from
+  #2656. (The collector does write ``cloud_profiles/*.json`` as of #2717; the
+  earlier claim that it did not is no longer true.)
+"""
+
+import asyncio
+import json
+import logging
+import os
+import re
+from dataclasses import dataclass, field as dataclasses_field
+from datetime import datetime, timezone
+
+import httpx
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.database import async_session
+from backend.app.models.archive import PrintArchive
+from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
+from backend.app.models.printer import Printer
+from backend.app.models.project import Project
+from backend.app.models.settings import Settings
+from backend.app.models.spool import Spool
+from backend.app.models.spool_usage_history import SpoolUsageHistory
+from backend.app.models.user import User
+from backend.app.schemas.github_backup import RestoreCategory
+from backend.app.services.git_providers.factory import get_provider_backend
+from backend.app.services.printer_manager import printer_manager
+
+logger = logging.getLogger(__name__)
+
+METADATA_PATH = "backup_metadata.json"
+SETTINGS_PATH = "settings/app_settings.json"
+SPOOLS_PATH = "spools/inventory.json"
+SPOOL_USAGE_PATH = "spools/usage_history.json"
+ARCHIVES_PATH = "archives/print_history.json"
+
+# kprofiles/{printer_serial}/{nozzle_diameter}.json
+_KPROFILE_PATH_RE = re.compile(r"^kprofiles/([^/]+)/([^/]+)\.json$")
+
+# Settings keys the backup collector already refuses to write. Applied again on
+# the read side because a backup taken before that denylist existed can still
+# contain them, and a restore must not resurrect a stale credential.
+_SENSITIVE_SETTING_KEYS = {"bambu_cloud_token", "auth_secret_key"}
+
+# The primary refusal, not a backstop for the set above. The collector filters
+# exactly bambu_cloud_token and auth_secret_key, so every other credential —
+# mqtt_password, ldap_bind_password, ha_token, prometheus_token — is present in
+# a current backup and is skipped only because its key matches a hint here.
+# _COMPANION_CREDENTIALS sits downstream of that: it withholds a toggle when the
+# credential it needs was refused, so shortening this tuple would both write a
+# stale credential and quietly make that rule inert.
+_SECRET_KEY_HINTS = ("token", "secret", "password", "access_code", "api_key", "passphrase")
+
+# Settings the MQTT relay reads only when it is (re)configured, so restoring the
+# rows is not enough on its own. Mirrors the set the settings PUT handler
+# watches. mqtt_password is in here for the configure() payload's sake — the
+# credential blocklist means a restore never writes it.
+_MQTT_SETTING_KEYS = {
+    "mqtt_enabled",
+    "mqtt_broker",
+    "mqtt_port",
+    "mqtt_username",
+    "mqtt_password",
+    "mqtt_topic_prefix",
+    "mqtt_use_tls",
+}
+
+# Keys that decide *who can reach the instance* rather than how it behaves. The
+# backup collector writes them like any other Settings row, so a backup taken
+# before auth was turned on carries auth_enabled=false — and a restore reaches
+# the table directly, so honouring them would:
+#
+#   * disable authentication outright. ``set_auth_enabled`` pairs its write with
+#     ``invalidate_auth_enabled_cache()``; we cannot, so the 30 s TTL in
+#     core.auth is the only thing between the write and an open instance. That
+#     cache is built to fail closed — writing the stored value behind its back
+#     is what would make it fail open.
+#   * bypass the lockout refusals ``update_settings`` enforces (a
+#     ``local_login_enabled=false`` with no enabled OIDC provider, or with no
+#     OIDC link on the caller, is a 400 there — #1589).
+#   * cross a permission boundary: a restore would be a way to rewrite auth
+#     config without SETTINGS_UPDATE. (The endpoint gates each category on the
+#     permission owning its rows now, but that is settings:update — still not
+#     the auth UI's own guards, which is what these keys actually need.)
+#
+# Auth is reconfigured through the auth UI, which has the guards. Restoring it
+# from a snapshot has no safe reading.
+_PROTECTED_SETTING_KEYS = {
+    "auth_enabled",
+    "advanced_auth_enabled",
+    "local_login_enabled",
+    "setup_completed",
+}
+
+# The LDAP family, refused for the same reason and by prefix rather than by
+# name, so a key added to the schema later is refused by default.
+#
+# These are not "how the instance behaves" settings — together they name *which
+# directory server decides who you are*. auth.py reads them live from this table
+# on every login (see the ldap_keys list in _get_ldap_settings), so a restore
+# that writes them substitutes the authentication source wholesale:
+# ldap_server_url points at another directory, ldap_auto_provision creates a
+# local account for whoever it vouches for, and ldap_default_group decides what
+# that account gets — Administrators, if the backup says so.
+#
+# The companion rule does NOT cover this, which is the trap. ldap_enabled is
+# paired with ldap_bind_password there, but an *anonymous* bind is a working
+# config, so a backup that simply omits the password skips the refusal at the
+# _COMPANION_EXPOSURE_TOGGLES check and the toggle is written. Omitting a
+# credential is exactly what an attacker authoring this file would do — they own
+# the directory being pointed at, so they need no bind credential from us.
+_PROTECTED_SETTING_PREFIXES = ("ldap_",)
+
+# Nozzle diameters the backup collector iterates. A path outside this set means
+# the backup was written by a newer version, so accept it rather than dropping
+# data, but keep the list for validation messages.
+_KNOWN_NOZZLES = {"0.2", "0.4", "0.6", "0.8"}
+
+
+def _parse_dt(value) -> datetime | None:
+    """Best-effort parse of a datetime the backup wrote via ``str(...)``.
+
+    Normalised to naive UTC, because that is what every ``DateTime`` column
+    here holds: the models write ``datetime.now(timezone.utc)`` into naive
+    columns and both dialects drop the offset on the way in. Carrying an aware
+    value through would store the wrong wall clock, and comparing one against a
+    value read back out of a naive column raises ``TypeError``. The collector
+    only ever writes naive strings, so this is a guard on hand-edited or
+    foreign backups rather than a path Bambuddy takes itself.
+    """
+    if not value or not isinstance(value, str):
+        return None
+    try:
+        parsed = datetime.fromisoformat(value)
+    except ValueError:
+        return None
+    if parsed.tzinfo is not None:
+        parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
+    return parsed
+
+
+def _created_at_matches(row, created_at: datetime | None) -> bool:
+    """Does ``row.created_at`` equal a timestamp read out of a backup?
+
+    Compared in Python, not in SQL, and that is the whole point. Every
+    ``created_at`` these callers dedupe on is ``server_default=func.now()``, so
+    SQLite fills it from ``CURRENT_TIMESTAMP``, which has second precision and
+    stores ``'2026-08-02 11:28:41'``. SQLAlchemy binds a Python datetime as
+    ``'2026-08-02 11:28:41.000000'``, and SQLite compares the two as strings —
+    so ``Model.created_at == created_at`` never matches a row the application
+    itself created, not even when handed that row's own value straight back.
+    Every dedupe keyed on it misses, and the restore inserts a duplicate of
+    everything instead of recognising what is already there.
+
+    Reading the candidates back and comparing the parsed datetimes sidesteps
+    the bind format entirely, and is equally correct on PostgreSQL (where the
+    column keeps microseconds and the SQL comparison happened to work).
+    """
+    return created_at is not None and row.created_at == created_at
+
+
+def _is_blocked_setting_key(key: str) -> bool:
+    lowered = key.lower()
+    return key in _SENSITIVE_SETTING_KEYS or any(hint in lowered for hint in _SECRET_KEY_HINTS)
+
+
+def _is_protected_setting_key(key: str) -> bool:
+    # Lowered for the prefix test for the same reason _is_blocked_setting_key
+    # lowers: the key comes from the backup's JSON, not from our own writer, so
+    # its casing is whatever the file says. An exact-match name stays exact —
+    # those four are ours and are only ever written lowercase.
+    return key in _PROTECTED_SETTING_KEYS or key.lower().startswith(_PROTECTED_SETTING_PREFIXES)
+
+
+# There used to be an ``_is_skipped_setting_key`` here, the union of the two
+# predicates above, shared by the preview and the restore so neither could drift
+# from the other. It is gone because a name is no longer enough to decide: the
+# third refusal below depends on the payload's *other* values and on local
+# database state. ``_plan_settings`` is the shared classifier now, and it covers
+# all three reasons.
+
+
+# Toggles whose *safety* depends on a companion credential that the blocklist
+# above refuses to restore. Writing the toggle alone is not a partial restore,
+# it is a downgrade:
+#
+#   * prometheus_enabled with no token opens /api/v1/metrics. The route is on
+#     PUBLIC_API_ROUTES and its own gate is ``if token:`` (api/routes/metrics.py),
+#     so an empty or absent token means no authentication at all — a full,
+#     unauthenticated dump of the instance to anyone who can reach the port. On
+#     an instance that never enabled Prometheus there is no token row, so
+#     overwrite-off alone is enough to do it.
+#   * the other four switch an integration on with no way to authenticate to it,
+#     which breaks the login path (LDAP) or the connection (MQTT, HA).
+#
+# virtual_printer_enabled is largely vestigial post-migration — core/database.py
+# copies the rows into the virtual_printers table — but it is the same shape, and
+# refusing a vestigial toggle is a harmless no-op.
+#
+# ldap_enabled is deliberately NOT here. It was, paired with
+# ldap_bind_password — but this rule judges availability ("will the integration
+# work?"), and that is the wrong question for an authentication source. An
+# anonymous bind is a working config, so the pair let a backup omit the password
+# and have the toggle written; the whole LDAP family is refused by prefix above
+# instead. _is_protected_setting_key runs first in _plan_settings, so leaving the
+# entry here would be dead code that reads like coverage.
+_COMPANION_CREDENTIALS = {
+    "prometheus_enabled": "prometheus_token",
+    "mqtt_enabled": "mqtt_password",
+    "ha_enabled": "ha_token",
+    "virtual_printer_enabled": "virtual_printer_access_code",
+}
+
+# Companion credentials a reader takes from the environment rather than from a
+# Settings row. ha_token is the only one: get_homeassistant_settings prefers
+# HA_TOKEN over the row, and auto-enables ha_enabled when HA_URL and HA_TOKEN are
+# both set, so an env-configured instance has a usable credential and no row.
+_COMPANION_CREDENTIAL_ENV = {"ha_token": "HA_TOKEN"}
+
+# The pairs above divide into two classes, because "did the *backup* carry a
+# usable credential?" does not mean the same thing for both.
+#
+# For the availability pairs it is the condition that stops the rule
+# over-refusing. An anonymous MQTT broker and an anonymous LDAP bind are working
+# configs, so a backup with an empty credential is describing something that
+# works, and refusing its toggle would be a false positive. Those pairs only
+# matter when the restore would produce a config weaker than *both* the backup
+# and the local instance.
+#
+# For the exposure pair it does not transfer. An empty prometheus_token removes
+# /api/v1/metrics' only gate (the route is on PUBLIC_API_ROUTES and its own
+# check is ``if token:``), so the exposure is a property of the toggle itself,
+# not of a downgrade relative to the backup: a backup taken on an instance that
+# enabled Prometheus *without* a token — the field is optional and defaults to
+# "" — is the more likely source of one, not the less. So an exposure toggle
+# skips this condition and is judged on local state alone.
+_COMPANION_EXPOSURE_TOGGLES = frozenset({"prometheus_enabled"})
+
+
+def _setting_value_is_true(value: object) -> bool:
+    """True if a settings *payload* value would be stored as "on".
+
+    Deliberately as narrow as ``api.routes.settings.setting_is_true``: a restore
+    writes ``str(value)`` verbatim and no reader in the codebase treats "1",
+    "on" or "yes" as on, so restoring one of those cannot switch anything on.
+    Bool-tolerant because a backup's JSON can carry a real boolean.
+    """
+    if isinstance(value, bool):
+        return value
+    if value is None:
+        return False
+    return str(value).strip().lower() == "true"
+
+
+def _is_usable_credential(value: object) -> bool:
+    """True if a credential value is present and not blank.
+
+    A present-but-*blank* ``prometheus_token`` row counts as unusable, because an
+    empty token is exactly the ``if token:`` hole the companion rule exists to
+    stop a restore from opening.
+    """
+    return value is not None and bool(str(value).strip())
+
+
+@dataclass(frozen=True)
+class _SettingsPlan:
+    """Which keys of a settings payload will not be written, and why.
+
+    Built once, before anything is added to the session, and shared by the
+    preview and the restore so the two cannot disagree about what a commit will
+    change. The companion bucket is why this needs a session at all: unlike the
+    two name-based buckets it depends on local database state.
+
+    The three buckets are disjoint — a key is classified once, in order.
+    """
+
+    blocked: tuple[str, ...] = ()
+    protected: tuple[str, ...] = ()
+    companion: tuple[str, ...] = ()
+
+    @property
+    def refused(self) -> frozenset[str]:
+        return frozenset(self.blocked) | frozenset(self.protected) | frozenset(self.companion)
+
+    @property
+    def refused_count(self) -> int:
+        return len(self.blocked) + len(self.protected) + len(self.companion)
+
+
+@dataclass(frozen=True)
+class _Detail:
+    """A preview caveat, as a translation code plus its English rendering.
+
+    Same contract as a note: the client translates ``code`` with ``params`` and
+    falls back to ``message``.
+    """
+
+    code: str
+    message: str
+    params: dict[str, str | int] = dataclasses_field(default_factory=dict)
+
+
+class _CategoryTally:
+    """Mutable accumulator matching ``GitHubRestoreCategoryResult``."""
+
+    def __init__(self) -> None:
+        self.restored = 0
+        self.skipped = 0
+        self.failed = 0
+        self.notes: list[dict] = []
+
+    def note(self, code: str, message: str, **params) -> None:
+        """Record a note as a translation code, its params and an English fallback.
+
+        Deduped on ``(code, params)`` rather than on the rendered text, which is
+        the same thing today but keeps two notes that differ only in a printer
+        name from collapsing into one. Bounded for the reason it always was: the
+        UI renders every note, so a large backup must not emit one per row.
+        """
+        if any(existing["code"] == code and existing["params"] == params for existing in self.notes):
+            return
+        if len(self.notes) >= 20:
+            return
+        self.notes.append({"code": code, "params": params, "message": message})
+
+    def as_dict(self) -> dict:
+        return {"restored": self.restored, "skipped": self.skipped, "failed": self.failed, "notes": self.notes}
+
+
+class GitHubRestoreService:
+    """Reads a backup repository and applies selected categories locally."""
+
+    def __init__(self) -> None:
+        self._running_restore: bool = False
+        self._progress: str | None = None
+        self._http_client: httpx.AsyncClient | None = None
+        # Guards the check-then-set on ``_running_restore``. Without it two
+        # concurrent POSTs can both observe False before either sets it.
+        self._lock = asyncio.Lock()
+
+    async def _get_client(self) -> httpx.AsyncClient:
+        if self._http_client is None or self._http_client.is_closed:
+            self._http_client = httpx.AsyncClient(timeout=60.0)
+        return self._http_client
+
+    @property
+    def is_running(self) -> bool:
+        return self._running_restore
+
+    @property
+    def progress(self) -> str | None:
+        return self._progress
+
+    # --- Repository reads --------------------------------------------------
+
+    async def list_commits(self, config: GitHubBackupConfig, limit: int = 20) -> dict:
+        """List recent commits on the configured branch."""
+        backend = get_provider_backend(config.provider)
+        client = await self._get_client()
+        result = await backend.list_commits(
+            repo_url=config.repository_url,
+            token=config.access_token,
+            branch=config.branch,
+            client=client,
+            limit=limit,
+        )
+        result["branch"] = config.branch
+        return result
+
+    async def _resolve_ref(self, config: GitHubBackupConfig, ref: str) -> tuple[str | None, str, dict | None]:
+        """Turn ``HEAD`` into a concrete commit SHA.
+
+        Done once up front so a preview and the restore that follows it act on
+        the same commit even if a scheduled backup lands in between.
+
+        The third element is the commit entry, when resolving already fetched
+        one. ``preview`` displays it, and taking it from here means the ``HEAD``
+        case — by far the common one — costs one ``list_commits`` call rather
+        than two.
+        """
+        if ref and ref.upper() != "HEAD":
+            return ref, "", None
+        result = await self.list_commits(config, limit=1)
+        if not result.get("success"):
+            return None, result.get("message") or "Could not read the backup repository", None
+        commits = result.get("commits") or []
+        if not commits:
+            return None, f"Branch '{config.branch}' has no commits to restore from", None
+        return commits[0]["sha"], "", commits[0]
+
+    async def _describe_commit(self, config: GitHubBackupConfig, resolved: str) -> dict | None:
+        """Find the display metadata for one commit SHA.
+
+        Two things used to leave ``commit: null`` in a preview, and the second is
+        the one that bit in practice:
+
+        * the commit is older than the 20 the picker lists, so it is not in the
+          scan at all — that is what ``get_commit`` is for;
+        * ``REF_PATTERN`` accepts a 7-character ref while providers return the
+          full 40, so an exact ``==`` never matched an abbreviated SHA *even when
+          the commit was in the window*. Hence the prefix comparison.
+
+        Best-effort throughout: this is a subject line and a date, so a failure
+        returns None and the preview renders without them rather than failing.
+        """
+        commits = (await self.list_commits(config, limit=20)).get("commits") or []
+        for entry in commits:
+            sha = entry.get("sha") or ""
+            if sha == resolved or sha.startswith(resolved) or resolved.startswith(sha):
+                return entry
+
+        backend = get_provider_backend(config.provider)
+        client = await self._get_client()
+        result = await backend.get_commit(
+            repo_url=config.repository_url, token=config.access_token, ref=resolved, client=client
+        )
+        return result.get("commit") if result.get("success") else None
+
+    def _category_paths(self, category: RestoreCategory, available: list[str]) -> list[str]:
+        """Return the paths in ``available`` that belong to ``category``."""
+        if category == RestoreCategory.SETTINGS:
+            return [p for p in (SETTINGS_PATH,) if p in available]
+        if category == RestoreCategory.SPOOLS:
+            return [p for p in (SPOOLS_PATH, SPOOL_USAGE_PATH) if p in available]
+        if category == RestoreCategory.ARCHIVES:
+            return [p for p in (ARCHIVES_PATH,) if p in available]
+        if category == RestoreCategory.KPROFILES:
+            return sorted(p for p in available if _KPROFILE_PATH_RE.match(p))
+        return []
+
+    @staticmethod
+    def _parse_json_files(raw: dict[str, str]) -> tuple[dict[str, object], list[str]]:
+        """Parse each fetched file, collecting paths that failed to parse."""
+        parsed: dict[str, object] = {}
+        bad: list[str] = []
+        for path, text in raw.items():
+            try:
+                parsed[path] = json.loads(text)
+            except (ValueError, TypeError):
+                bad.append(path)
+        return parsed, bad
+
+    @staticmethod
+    async def _plan_settings(db: AsyncSession, values: dict) -> _SettingsPlan:
+        """Classify every key of a settings payload into its refusal bucket.
+
+        Keys with an unusable name land in no bucket: they are the restore's
+        ``failed``, not a refusal, and the preview counts them because the run
+        will still report on them.
+
+        Reads local state, so it must run before anything is added to the
+        session — otherwise "does this instance already have a credential" would
+        see the restore's own writes.
+        """
+        blocked: list[str] = []
+        protected: list[str] = []
+        # Toggle -> credential for the pairs that survived the payload-only
+        # conditions and still need local state to judge.
+        candidates: dict[str, str] = {}
+
+        for key, value in values.items():
+            if not isinstance(key, str) or not key:
+                continue
+            if _is_blocked_setting_key(key):
+                blocked.append(key)
+                continue
+            if _is_protected_setting_key(key):
+                protected.append(key)
+                continue
+
+            credential = _COMPANION_CREDENTIALS.get(key)
+            if credential is None:
+                continue
+            # Turning something *off* is always safe to write.
+            if not _setting_value_is_true(value):
+                continue
+            # Expressed as the predicate rather than assumed, so the map cannot
+            # go quietly inert if _SECRET_KEY_HINTS is ever edited: a credential
+            # the restore is willing to write travels with its toggle.
+            if not _is_blocked_setting_key(credential):
+                continue
+            # The backup itself carried no credential here. For an availability
+            # pair that describes a working config — an anonymous MQTT broker and
+            # an anonymous LDAP bind both are (mqtt_relay.py and ldap_service.py
+            # pass empty credentials straight through) — so refusing the toggle
+            # would be a false positive. For an exposure pair a blank credential
+            # is the hole itself, so the condition is skipped and only local
+            # state decides. See _COMPANION_EXPOSURE_TOGGLES.
+            if key not in _COMPANION_EXPOSURE_TOGGLES and not _is_usable_credential(values.get(credential)):
+                continue
+            candidates[key] = credential
+
+        if not candidates:
+            return _SettingsPlan(blocked=tuple(blocked), protected=tuple(protected))
+
+        # One SELECT covering both halves of every candidate pair.
+        wanted = set(candidates) | set(candidates.values())
+        rows = await db.execute(select(Settings).where(Settings.key.in_(wanted)))
+        local = {row.key: row.value for row in rows.scalars().all()}
+
+        companion: list[str] = []
+        for toggle, credential in candidates.items():
+            if _is_usable_credential(local.get(credential)):
+                continue
+            env_name = _COMPANION_CREDENTIAL_ENV.get(credential)
+            if env_name and _is_usable_credential(os.environ.get(env_name)):
+                continue
+            # Already on locally with no credential: the exposure pre-dates this
+            # restore, so refusing changes nothing and "left switched off" would
+            # be a lie.
+            if _setting_value_is_true(local.get(toggle)):
+                continue
+            companion.append(toggle)
+
+        return _SettingsPlan(
+            blocked=tuple(blocked),
+            protected=tuple(protected),
+            companion=tuple(companion),
+        )
+
+    async def preview(self, db: AsyncSession, config: GitHubBackupConfig, ref: str = "HEAD") -> dict:
+        """Report which categories a commit contains, and how much is in each.
+
+        Takes a session because the settings count depends on local state — see
+        ``_plan_settings``. ``ref`` stays keyword-friendly for callers.
+        """
+        resolved, error, commit_info = await self._resolve_ref(config, ref)
+        if resolved is None:
+            return {"success": False, "message": error, "ref": ref, "categories": []}
+
+        backend = get_provider_backend(config.provider)
+        client = await self._get_client()
+
+        tree = await backend.list_tree(
+            repo_url=config.repository_url, token=config.access_token, ref=resolved, client=client
+        )
+        if not tree.get("success"):
+            return {"success": False, "message": tree.get("message") or "Could not list the commit", "ref": resolved}
+        available: list[str] = tree.get("paths") or []
+
+        # One batched read covers metadata plus every category payload.
+        wanted = [METADATA_PATH] if METADATA_PATH in available else []
+        for category in RestoreCategory:
+            wanted.extend(self._category_paths(category, available))
+
+        fetched = await backend.fetch_files(
+            repo_url=config.repository_url,
+            token=config.access_token,
+            ref=resolved,
+            paths=wanted,
+            client=client,
+            # The listing above already built this map; without it the GitHub
+            # family would GET the same recursive tree a second time.
+            blob_shas=tree.get("blob_shas") or None,
+        )
+        if not fetched.get("success"):
+            return {
+                "success": False,
+                "message": fetched.get("message") or "Could not read the commit contents",
+                "ref": resolved,
+            }
+        parsed, bad_paths = self._parse_json_files(fetched.get("files") or {})
+
+        metadata = parsed.get(METADATA_PATH)
+        metadata_version = metadata.get("version") if isinstance(metadata, dict) else None
+
+        categories = []
+        for category in RestoreCategory:
+            paths = self._category_paths(category, available)
+            if not paths:
+                categories.append(
+                    self._category_entry(category, False, 0, _Detail("notPresent", "Not present in this backup commit"))
+                )
+                continue
+            unreadable = [p for p in paths if p in bad_paths]
+            if unreadable:
+                joined = ", ".join(unreadable)
+                categories.append(
+                    self._category_entry(
+                        category,
+                        False,
+                        0,
+                        _Detail("unreadableJson", f"Unreadable JSON: {joined}", {"paths": joined}),
+                    )
+                )
+                continue
+            count, detail = await self._count_items(db, category, parsed)
+            categories.append(self._category_entry(category, True, count, detail))
+
+        if commit_info is None:
+            commit_info = await self._describe_commit(config, resolved)
+
+        return {
+            "success": True,
+            "message": "OK",
+            "ref": resolved,
+            "commit": commit_info,
+            "metadata_version": metadata_version,
+            "categories": categories,
+        }
+
+    @staticmethod
+    def _category_entry(category: RestoreCategory, available: bool, item_count: int, detail: _Detail | None) -> dict:
+        """Shape one ``GitHubRestorePreviewCategory``, translated detail included."""
+        return {
+            "category": category,
+            "available": available,
+            "item_count": item_count,
+            "detail": detail.message if detail else None,
+            "detail_code": detail.code if detail else None,
+            "detail_params": detail.params if detail else {},
+        }
+
+    async def _count_items(
+        self, db: AsyncSession, category: RestoreCategory, parsed: dict
+    ) -> tuple[int, _Detail | None]:
+        """Count restorable items for ``category`` and describe any caveat."""
+        if category == RestoreCategory.SETTINGS:
+            payload = parsed.get(SETTINGS_PATH)
+            values = payload.get("settings") if isinstance(payload, dict) else None
+            if not isinstance(values, dict):
+                return 0, _Detail("settingsNoPayload", "No settings in payload")
+            # Every refusal is subtracted so the count matches what the restore
+            # actually writes. The wording calls out the credential ones (what a
+            # user might expect to come back) and the companion ones (a
+            # behaviour change worth explaining before it happens); the auth
+            # policy keys stay unmentioned on purpose.
+            plan = await self._plan_settings(db, values)
+            detail = None
+            if plan.companion and not plan.blocked:
+                # An exposure toggle becomes a candidate whether or not the
+                # backup carried its credential, so this commit can refuse a
+                # switch without having a single credential-like key to skip —
+                # "0 credential-like key(s) will be skipped" would read as noise.
+                detail = _Detail(
+                    "settingsCompanionOnlyWillSkip",
+                    f"{len(plan.companion)} switch(es) will be left off — the credential each one needs "
+                    "cannot be restored from a backup",
+                    {"companion": len(plan.companion)},
+                )
+            elif plan.companion:
+                detail = _Detail(
+                    "settingsCompanionWillSkip",
+                    f"{len(plan.blocked)} credential-like key(s) will be skipped, and "
+                    f"{len(plan.companion)} switch(es) that depend on them will be left off",
+                    {"count": len(plan.blocked), "companion": len(plan.companion)},
+                )
+            elif plan.blocked:
+                detail = _Detail(
+                    "settingsCredentialsWillSkip",
+                    f"{len(plan.blocked)} credential-like keys will be skipped",
+                    {"count": len(plan.blocked)},
+                )
+            return len(values) - plan.refused_count, detail
+
+        if category == RestoreCategory.SPOOLS:
+            payload = parsed.get(SPOOLS_PATH)
+            spools = payload.get("spools") if isinstance(payload, dict) else None
+            usage_payload = parsed.get(SPOOL_USAGE_PATH)
+            usage = usage_payload.get("usage_history") if isinstance(usage_payload, dict) else None
+            # Usage records are counted here, not just described in the detail:
+            # _restore_spool_usage increments this category's tally, so counting
+            # only the spools broke restored + skipped + failed == item_count —
+            # the invariant the settings count is careful to hold. The detail
+            # breaks the total down rather than adding to it.
+            count = len(spools) if isinstance(spools, list) else 0
+            detail = None
+            if isinstance(usage, list) and usage:
+                count += len(usage)
+                detail = _Detail("spoolsUsageCount", f"including {len(usage)} usage records", {"count": len(usage)})
+            return count, detail
+
+        if category == RestoreCategory.ARCHIVES:
+            payload = parsed.get(ARCHIVES_PATH)
+            archives = payload.get("archives") if isinstance(payload, dict) else None
+            count = len(archives) if isinstance(archives, list) else 0
+            return count, _Detail(
+                "archivesMetadataOnly", "Metadata only — 3MF files and thumbnails are not in a Git backup"
+            )
+
+        if category == RestoreCategory.KPROFILES:
+            total = 0
+            serials = set()
+            for path, payload in parsed.items():
+                match = _KPROFILE_PATH_RE.match(path)
+                if not match or not isinstance(payload, dict):
+                    continue
+                serials.add(match.group(1))
+                profiles = payload.get("profiles")
+                if isinstance(profiles, list):
+                    total += len(profiles)
+            detail = None
+            if serials:
+                detail = _Detail("kprofilesPrinterCount", f"across {len(serials)} printer(s)", {"count": len(serials)})
+            return total, detail
+
+        return 0, None
+
+    # --- Restore -----------------------------------------------------------
+
+    async def run_restore(
+        self,
+        config_id: int,
+        ref: str,
+        categories: list[RestoreCategory],
+        overwrite_existing: bool = False,
+    ) -> dict:
+        """Apply selected categories from one backup commit."""
+        # Import locally to avoid a module-level cycle: the backup service takes
+        # the mirror-image lock against us.
+        from backend.app.services.github_backup import github_backup_service
+
+        # The lock serialises two concurrent restores; the backup side has no
+        # lock of its own, and relies on this region staying await-free after the
+        # acquisition. Both flags are plain bools on one event loop, so with no
+        # suspension point between the two reads and the write, the loop cannot
+        # slip github_backup.run_backup's mirror-image check in between. Adding an
+        # `await` below the acquisition and above `self._running_restore = True`
+        # would let a backup and a restore run at once.
+        async with self._lock:
+            if self._running_restore:
+                return {"success": False, "message": "A restore is already running", "results": {}}
+            if github_backup_service.is_running:
+                return {
+                    "success": False,
+                    "message": "A backup is currently running. Wait for it to finish before restoring.",
+                    "results": {},
+                }
+            self._running_restore = True
+
+        log_id = None
+        try:
+            async with async_session() as db:
+                result = await db.execute(select(GitHubBackupConfig).where(GitHubBackupConfig.id == config_id))
+                config = result.scalar_one_or_none()
+                if not config:
+                    return {"success": False, "message": "Configuration not found", "results": {}}
+
+                self._progress = "Resolving commit..."
+                resolved, error, _ = await self._resolve_ref(config, ref)
+                if resolved is None:
+                    return {"success": False, "message": error, "results": {}}
+
+                log = GitHubBackupLog(config_id=config_id, status="running", trigger="restore", commit_sha=resolved)
+                db.add(log)
+                await db.commit()
+                await db.refresh(log)
+                log_id = log.id
+
+                # Owned here rather than by _apply so the failure path can see
+                # the categories that were already committed when the raise
+                # happened. _apply records a tally only after its category's
+                # commit, so every entry present is on disk.
+                results: dict[str, _CategoryTally] = {}
+                try:
+                    payload, error = await self._read_categories(config, resolved, categories)
+                    if error:
+                        raise RuntimeError(error)
+
+                    settings_keys_written: set[str] = set()
+                    await self._apply(
+                        db, payload, categories, overwrite_existing, settings_keys_written, results=results
+                    )
+                    await db.commit()
+
+                    # After the commit: this reconnects the relay, which is not
+                    # something to do on values that could still roll back.
+                    settings_tally = results.get(RestoreCategory.SETTINGS.value)
+                    if settings_tally is not None:
+                        self._progress = "Reconnecting the MQTT relay..."
+                        await self._reconfigure_mqtt_relay(db, settings_keys_written, settings_tally)
+
+                    total_restored = sum(tally.restored for tally in results.values())
+                    any_failed = any(tally.failed for tally in results.values())
+
+                    log.status = "failed" if any_failed and total_restored == 0 else "success"
+                    log.completed_at = datetime.now(timezone.utc)
+                    log.files_changed = total_restored
+                    if any_failed:
+                        log.error_message = "Some items could not be restored — see the restore result for detail"
+                    await db.commit()
+
+                    return {
+                        "success": True,
+                        "message": f"Restored {total_restored} item(s) from {resolved[:7]}",
+                        "log_id": log_id,
+                        "ref": resolved,
+                        "results": {name: tally.as_dict() for name, tally in results.items()},
+                    }
+
+                except Exception as e:
+                    # Rolls back the category that was mid-flight. Every category
+                    # already in ``results`` committed as it finished (see
+                    # _apply), so those rows survive this — and reporting an
+                    # empty result over them would tell the user nothing was
+                    # restored while their archives and spools are on disk.
+                    logger.exception("Restore failed for config %s ref %s", config_id, resolved)
+                    await db.rollback()
+                    committed = sum(tally.restored for tally in results.values())
+                    log.status = "failed"
+                    log.completed_at = datetime.now(timezone.utc)
+                    log.files_changed = committed
+                    log.error_message = str(e)[:1000]
+                    await db.commit()
+                    return {
+                        "success": False,
+                        "message": str(e),
+                        "log_id": log_id,
+                        "ref": resolved,
+                        "results": {name: tally.as_dict() for name, tally in results.items()},
+                    }
+
+        finally:
+            self._running_restore = False
+            self._progress = None
+
+    async def _read_categories(
+        self, config: GitHubBackupConfig, ref: str, categories: list[RestoreCategory]
+    ) -> tuple[dict, str]:
+        """Fetch and parse just the files the requested categories need."""
+        backend = get_provider_backend(config.provider)
+        client = await self._get_client()
+
+        self._progress = "Listing backup contents..."
+        tree = await backend.list_tree(
+            repo_url=config.repository_url, token=config.access_token, ref=ref, client=client
+        )
+        if not tree.get("success"):
+            return {}, tree.get("message") or "Could not list the commit"
+        available: list[str] = tree.get("paths") or []
+
+        wanted: list[str] = []
+        for category in categories:
+            wanted.extend(self._category_paths(category, available))
+        if not wanted:
+            return {}, "None of the selected categories are present in that commit"
+
+        self._progress = "Downloading backup files..."
+        fetched = await backend.fetch_files(
+            repo_url=config.repository_url,
+            token=config.access_token,
+            ref=ref,
+            paths=wanted,
+            client=client,
+            blob_shas=tree.get("blob_shas") or None,
+        )
+        if not fetched.get("success"):
+            return {}, fetched.get("message") or "Could not read the commit contents"
+
+        parsed, bad = self._parse_json_files(fetched.get("files") or {})
+        if bad:
+            return {}, f"Backup contains unreadable JSON: {', '.join(sorted(bad))}"
+        return parsed, ""
+
+    async def _apply(
+        self,
+        db: AsyncSession,
+        payload: dict,
+        categories: list[RestoreCategory],
+        overwrite: bool,
+        settings_keys_written: set[str] | None = None,
+        results: dict[str, _CategoryTally] | None = None,
+    ) -> dict[str, _CategoryTally]:
+        """Apply categories in dependency order and return per-category tallies.
+
+        ``settings_keys_written``, if given, collects the setting keys actually
+        written, for the caller's post-commit side effects (see
+        ``_reconfigure_mqtt_relay``).
+
+        ``results``, if given, is the caller's own dict rather than a fresh one.
+        Each category is committed before it is recorded there, so on a raise
+        the caller can report exactly what is already on disk — see the
+        per-category commit below.
+        """
+        results = {} if results is None else results
+        archive_id_map: dict[int, int] = {}
+
+        # Every database category commits before the next one starts, and only
+        # then is its tally recorded. Two reasons:
+        #
+        #  * SQLite has one writer. Each category is a long run of one SELECT per
+        #    row or per key — _find_archive, _find_spool, the usage dedupe,
+        #    _restore_settings — interleaved with autoflushed INSERTs, all inside
+        #    the open write transaction. A few thousand archives plus a full
+        #    usage history plausibly passes the 15 s busy_timeout
+        #    (core/database.py), at which point every concurrent writer in the
+        #    app fails with "database is locked". This is the same hold the
+        #    K-profile phase had, arriving by volume rather than by awaiting a
+        #    sulking printer.
+        #  * The ordering tolerates it: the only cross-category state is
+        #    archive_id_map and spool_id_map, both plain dicts in memory, and
+        #    the session is expire_on_commit=False so nothing reloads.
+        #
+        # The cost is that a later failure no longer rolls back an earlier
+        # category — which is why the tally is recorded after the commit, so
+        # run_restore's failure path reports the rows that really landed instead
+        # of claiming nothing was restored.
+
+        # Archives first: spool usage history references archive_id.
+        if RestoreCategory.ARCHIVES in categories:
+            self._progress = "Restoring print archives..."
+            tally = _CategoryTally()
+            await self._restore_archives(db, payload.get(ARCHIVES_PATH), overwrite, tally, archive_id_map)
+            await db.commit()
+            results[RestoreCategory.ARCHIVES.value] = tally
+
+        if RestoreCategory.SPOOLS in categories:
+            self._progress = "Restoring spool inventory..."
+            tally = _CategoryTally()
+            await self._restore_spools(
+                db,
+                payload.get(SPOOLS_PATH),
+                payload.get(SPOOL_USAGE_PATH),
+                overwrite,
+                tally,
+                archive_id_map,
+            )
+            await db.commit()
+            results[RestoreCategory.SPOOLS.value] = tally
+
+        if RestoreCategory.SETTINGS in categories:
+            self._progress = "Restoring app settings..."
+            tally = _CategoryTally()
+            await self._restore_settings(
+                db, payload.get(SETTINGS_PATH), overwrite, tally, keys_written=settings_keys_written
+            )
+            await db.commit()
+            results[RestoreCategory.SETTINGS.value] = tally
+
+        # Last, because it leaves the database and publishes over MQTT.
+        if RestoreCategory.KPROFILES in categories:
+            # The database categories are already committed by the loop above,
+            # and that is load-bearing here rather than tidiness:
+            # _restore_kprofiles awaits get_kprofiles per printer per nozzle,
+            # which is timeout=5.0 * max_retries=3, i.e. up to ~15 s each against
+            # an unresponsive printer. Holding SQLite's writer across that would
+            # pass the 15 s busy_timeout on a farm with a couple of sulking
+            # printers.
+            #
+            # The cost is that a K-profile failure no longer rolls back the
+            # categories that already succeeded. That is the correct trade
+            # anyway: extrusion_cali_set has left for the printer by then and
+            # cannot be rolled back either, so a rollback would only have made
+            # the database disagree with the hardware.
+
+            self._progress = "Sending K-profiles to printers..."
+            tally = _CategoryTally()
+            try:
+                await self._restore_kprofiles(db, payload, tally)
+            except Exception as e:
+                # Everything above is committed and cannot be un-committed, so
+                # letting this reach run_restore's handler would report
+                # "nothing was restored" over durable archive, spool and
+                # settings rows — and skip the post-commit MQTT reconfigure,
+                # leaving the relay on the pre-restore broker. The K-profile
+                # phase is the last thing that runs, so containing it here is
+                # what keeps the result honest about what actually landed.
+                logger.exception("The K-profile step failed after the database categories were committed")
+                # Discards the phase's own read transaction. The rows above went
+                # in at the commit two statements up; this only stops a session
+                # left in a failed state by a database error from turning the
+                # caller's commit into that same false report.
+                await db.rollback()
+                outstanding = self._kprofile_profile_count(
+                    content for path, content in payload.items() if _KPROFILE_PATH_RE.match(path)
+                )
+                outstanding -= tally.restored + tally.skipped + tally.failed
+                tally.failed += max(outstanding, 0)
+                tally.note(
+                    "kprofilesStepFailed",
+                    f"The K-profile step could not be completed: {e}",
+                    reason=str(e)[:200],
+                )
+            results[RestoreCategory.KPROFILES.value] = tally
+
+        return results
+
+    # --- Per-category appliers --------------------------------------------
+
+    async def _restore_archives(
+        self,
+        db: AsyncSession,
+        payload,
+        overwrite: bool,
+        tally: _CategoryTally,
+        id_map: dict[int, int],
+    ) -> None:
+        archives = payload.get("archives") if isinstance(payload, dict) else None
+        if not isinstance(archives, list):
+            tally.note("noData", "No data of this kind in this backup")
+            return
+
+        valid_printers = set((await db.execute(select(Printer.id))).scalars().all())
+        valid_projects = set((await db.execute(select(Project.id))).scalars().all())
+        # Ownership decides visibility, not just attribution: an archive with a
+        # NULL created_by_id is a 404 to every caller without archives:read_all
+        # (_ensure_archive_visible fails closed on it) and never appears in the
+        # ownership-scoped list queries. Hoisted like the two above.
+        #
+        # username is the natural key and wins, per the module's rule at the top
+        # of the file; created_by_id is the fallback for a pre-#2656 commit that
+        # carries no username. That ordering is what makes restoring onto a
+        # rebuilt instance safe: the users table renumbers there, so a live id
+        # can land on a different person, and the id path alone cannot tell that
+        # from a correct match. Resolving on the name instead means the one case
+        # it cannot resolve — a user renamed since the backup — falls through to
+        # ownerless-with-a-note below rather than misattributing in silence.
+        users = (await db.execute(select(User.id, User.username))).all()
+        valid_users = {user_id for user_id, _ in users}
+        users_by_name = {username: user_id for user_id, username in users}
+
+        # Only metadata is backed up, never the 3MF/thumbnail bytes, and
+        # print_archives.file_path is NOT NULL — so inserted rows get an empty
+        # path and are history-only. Say so once rather than per row.
+        warned_files = False
+
+        for entry in archives:
+            if not isinstance(entry, dict):
+                tally.failed += 1
+                continue
+
+            old_id = entry.get("id") if isinstance(entry.get("id"), int) else None
+            started_at = _parse_dt(entry.get("started_at"))
+            existing = await self._find_archive(db, entry, started_at)
+
+            fields = {
+                "print_name": entry.get("print_name"),
+                "print_time_seconds": entry.get("print_time_seconds"),
+                "filament_used_grams": entry.get("filament_used_grams"),
+                "filament_type": entry.get("filament_type"),
+                "filament_color": entry.get("filament_color"),
+                "layer_height": entry.get("layer_height"),
+                "total_layers": entry.get("total_layers"),
+                "nozzle_diameter": entry.get("nozzle_diameter"),
+                "bed_temperature": entry.get("bed_temperature"),
+                "nozzle_temperature": entry.get("nozzle_temperature"),
+                "sliced_for_model": entry.get("sliced_for_model"),
+                "status": entry.get("status") or "completed",
+                "started_at": started_at,
+                "completed_at": _parse_dt(entry.get("completed_at")),
+                "makerworld_url": entry.get("makerworld_url"),
+                "designer": entry.get("designer"),
+                "external_url": entry.get("external_url"),
+                "is_favorite": bool(entry.get("is_favorite")),
+                "tags": entry.get("tags"),
+                "notes": entry.get("notes"),
+                "cost": entry.get("cost"),
+                "failure_reason": entry.get("failure_reason"),
+                "quantity": entry.get("quantity") or 1,
+                "energy_kwh": entry.get("energy_kwh"),
+                "energy_cost": entry.get("energy_cost"),
+            }
+
+            printer_id = entry.get("printer_id")
+            if printer_id is not None and printer_id not in valid_printers:
+                tally.note(
+                    "archivesPrinterMissing", "Some archives referenced printers that no longer exist — link cleared"
+                )
+                printer_id = None
+            project_id = entry.get("project_id")
+            if project_id is not None and project_id not in valid_projects:
+                tally.note(
+                    "archivesProjectMissing", "Some archives referenced projects that no longer exist — link cleared"
+                )
+                project_id = None
+            fields["printer_id"] = printer_id
+            fields["project_id"] = project_id
+
+            # The ownership pair and deleted_at are the late arrivals — a backup
+            # commit taken before the collector wrote them carries neither key.
+            # Absent is NOT the same as null here, because the overwrite branch
+            # below is a blanket setattr: treating a missing key as None would
+            # write NULL over a live owner (_ensure_archive_visible then 404s the
+            # archive for the very user who owns it — the failure carrying the
+            # column was added to fix) and silently un-delete a row the user
+            # deleted. So only carry a column the backup actually knows about;
+            # on insert, an absent key just takes the model default.
+            # An owner the backup names but this instance cannot resolve is the
+            # same epistemic state as an absent key — we do not know who owns
+            # this archive — so it takes the same action: the column is left out
+            # of ``fields`` entirely rather than set to None. Writing NULL there
+            # would take the owner away from a local archive that has a perfectly
+            # good one, which is the 404-for-its-own-owner failure this column is
+            # carried across to fix, and it would do it on the overwrite path
+            # where there is a local answer to keep. On insert there is nothing
+            # to keep, so the row takes the model default and lands ownerless,
+            # which is what the note says.
+            owner_cleared = False
+            backup_username = entry.get("created_by_username")
+            if isinstance(backup_username, str) and backup_username:
+                # The natural-key path. A miss here is a user renamed or deleted
+                # since the backup, and there is nothing else to resolve on: the
+                # id alongside it is from the source instance's numbering, so
+                # trusting it is exactly the misattribution the name is here to
+                # prevent. Not a reason to fail the row — the archive is still
+                # worth having, and an admin can reassign it — but said out loud
+                # on insert, because an ownerless archive is not silent-safe.
+                created_by_id = users_by_name.get(backup_username)
+                if created_by_id is None:
+                    if existing is None:
+                        tally.note(
+                            "archivesOwnerUnmatched",
+                            "Some archives name an owner this instance does not have — owner cleared rather than "
+                            "guessed from the backup's user id, so they are visible only to users with the "
+                            "archives:read_all permission until an admin reassigns them",
+                        )
+                        owner_cleared = True
+                else:
+                    fields["created_by_id"] = created_by_id
+            elif "created_by_id" in entry:
+                # Fallback for a commit taken before the collector recorded the
+                # username. Validated rather than trusted, so a *stale* id is
+                # dropped instead of pointing somewhere wrong; a live id
+                # belonging to a different person on a rebuilt instance is the
+                # case this path cannot see, and is why the branch above exists.
+                # An explicit null is not a miss — the backup is saying the
+                # archive had no owner — so it is written, and overwrite keeps
+                # meaning "make the local row match the backup".
+                created_by_id = entry.get("created_by_id")
+                if created_by_id is not None and created_by_id not in valid_users:
+                    if existing is None:
+                        tally.note(
+                            "archivesOwnerCleared",
+                            "Some archives referenced users that no longer exist — owner cleared, so they are "
+                            "visible only to users with the archives:read_all permission until an admin "
+                            "reassigns them",
+                        )
+                        owner_cleared = True
+                else:
+                    fields["created_by_id"] = created_by_id
+            if "deleted_at" in entry:
+                # A soft-deleted archive is still in the backup (its row is kept
+                # so stats keep counting it), so carry the flag across or the
+                # restore turns something the user deleted back into a visible
+                # archive.
+                fields["deleted_at"] = _parse_dt(entry.get("deleted_at"))
+
+            if existing is not None:
+                if old_id is not None:
+                    id_map[old_id] = existing.id
+                if not overwrite:
+                    tally.skipped += 1
+                    continue
+                # Overwrite means "make the local row match the backup", which
+                # includes un-deleting one the user deleted after the backup was
+                # taken. Legitimate, but not obvious from a restored/skipped
+                # count, so say it.
+                if existing.deleted_at is not None and "deleted_at" in fields and fields["deleted_at"] is None:
+                    tally.note(
+                        "archivesUndeleted",
+                        "Archive(s) deleted since the backup are visible again — overwrite was on",
+                    )
+                for key, value in fields.items():
+                    setattr(existing, key, value)
+                tally.restored += 1
+                continue
+
+            if not warned_files:
+                tally.note(
+                    "archivesMetadataOnly",
+                    "Restored archives carry metadata only — the 3MF and thumbnail files are not in a Git backup",
+                )
+                warned_files = True
+
+            # Insert-only, and the mirror of the rule above: an owner the backup
+            # cannot tell us is never written, so on overwrite the local one
+            # survives — but there is no local row here to fall back on, so the
+            # archive lands ownerless, a 404 for everyone without
+            # archives:read_all. Three ways to get here: a commit taken before
+            # the collector recorded the column (every pre-#2656 backup), an
+            # archive that genuinely had no owner on the source instance, or one
+            # whose owner this instance cannot resolve. All restore fine and all
+            # were silent, so the tally said "N archives restored" while the user
+            # who asked for them saw none. The unresolved cases above already
+            # said their piece; don't say it twice for the same row.
+            if fields.get("created_by_id") is None and not owner_cleared:
+                tally.note(
+                    "archivesOwnerUnknown",
+                    "Some archives were restored without an owner — this backup does not record one, so they "
+                    "are visible only to users with the archives:read_all permission until an admin reassigns "
+                    "them",
+                )
+
+            row = PrintArchive(
+                filename=entry.get("filename") or "restored-from-backup",
+                file_path="",
+                file_size=entry.get("file_size") or 0,
+                content_hash=entry.get("content_hash"),
+                **fields,
+            )
+            created_at = _parse_dt(entry.get("created_at"))
+            if created_at is not None:
+                row.created_at = created_at
+            db.add(row)
+            await db.flush()
+            if old_id is not None:
+                id_map[old_id] = row.id
+            tally.restored += 1
+
+    async def _find_archive(self, db: AsyncSession, entry: dict, started_at: datetime | None) -> PrintArchive | None:
+        """Match a backed-up archive to a local row by natural key.
+
+        ``started_at`` is nullable and genuinely NULL for a whole class of rows —
+        the re-slice path in ``library.py`` constructs ``PrintArchive`` without
+        one — so it cannot be *required* by the key. It narrows the match instead:
+        a backed-up row with no ``started_at`` matches a local row that has none
+        either. Requiring it meant those archives never matched, so each restore
+        re-inserted them as duplicates and overwrite mode could never update them.
+
+        ``content_hash`` identifies the sliced file on its own, which is why it is
+        the branch allowed to run without a ``started_at``; ``filename`` is too
+        weak for that (re-slices share it) and still requires one. Two backed-up
+        rows sharing a hash *and* having no ``started_at`` are indistinguishable
+        in the backup, so they collapse onto one local row — better than
+        duplicating both on every restore.
+
+        Soft-deleted rows are matched deliberately: there is no ``deleted_at``
+        filter here because the row still exists, and matching it is what stops a
+        restore inserting a live duplicate of an archive the user has deleted.
+        """
+        started_predicate = PrintArchive.started_at == started_at if started_at else PrintArchive.started_at.is_(None)
+
+        content_hash = entry.get("content_hash")
+        if content_hash:
+            result = await db.execute(
+                select(PrintArchive).where(PrintArchive.content_hash == content_hash, started_predicate)
+            )
+            row = result.scalars().first()
+            if row is not None:
+                return row
+
+        filename = entry.get("filename")
+        if filename and started_at:
+            result = await db.execute(select(PrintArchive).where(PrintArchive.filename == filename, started_predicate))
+            return result.scalars().first()
+        return None
+
+    async def _restore_spools(
+        self,
+        db: AsyncSession,
+        inventory,
+        usage_payload,
+        overwrite: bool,
+        tally: _CategoryTally,
+        archive_id_map: dict[int, int],
+    ) -> None:
+        spools = inventory.get("spools") if isinstance(inventory, dict) else None
+        if not isinstance(spools, list):
+            tally.note("noData", "No data of this kind in this backup")
+            return
+
+        spool_id_map: dict[int, int] = {}
+        tags_kept = 0
+
+        for entry in spools:
+            if not isinstance(entry, dict):
+                tally.failed += 1
+                continue
+
+            old_id = entry.get("id") if isinstance(entry.get("id"), int) else None
+            existing, matched_on = await self._find_spool(db, entry)
+
+            fields = {
+                "material": entry.get("material") or "PLA",
+                "subtype": entry.get("subtype"),
+                "color_name": entry.get("color_name"),
+                "rgba": entry.get("rgba"),
+                "brand": entry.get("brand"),
+                "label_weight": entry.get("label_weight") or 1000,
+                "core_weight": entry.get("core_weight") or 250,
+                "weight_used": entry.get("weight_used") or 0,
+                "weight_locked": bool(entry.get("weight_locked")),
+                "slicer_filament": entry.get("slicer_filament"),
+                "slicer_filament_name": entry.get("slicer_filament_name"),
+                "nozzle_temp_min": entry.get("nozzle_temp_min"),
+                "nozzle_temp_max": entry.get("nozzle_temp_max"),
+                "note": entry.get("note"),
+                "cost_per_kg": entry.get("cost_per_kg"),
+                "tag_uid": entry.get("tag_uid"),
+                "tray_uuid": entry.get("tray_uuid"),
+                "data_origin": entry.get("data_origin"),
+                "tag_type": entry.get("tag_type"),
+                "archived_at": _parse_dt(entry.get("archived_at")),
+            }
+
+            if existing is not None:
+                if old_id is not None:
+                    spool_id_map[old_id] = existing.id
+                if not overwrite:
+                    tally.skipped += 1
+                    continue
+                tags_kept += await self._guard_tag_overwrite(db, existing, fields, matched_on)
+                for key, value in fields.items():
+                    setattr(existing, key, value)
+                tally.restored += 1
+                continue
+
+            row = Spool(**fields)
+            # Carry the original created_at across. Without it the row would be
+            # stamped "now", and the composite fallback in _find_spool (which
+            # keys on created_at) would miss on a second restore and insert a
+            # duplicate instead of matching.
+            created_at = _parse_dt(entry.get("created_at"))
+            if created_at is not None:
+                row.created_at = created_at
+            db.add(row)
+            await db.flush()
+            if old_id is not None:
+                spool_id_map[old_id] = row.id
+            tally.restored += 1
+
+        if tags_kept:
+            tally.note(
+                "spoolTagKept",
+                f"{tags_kept} spool tag(s) left as they are — the backup would have cleared a tag that "
+                "has since been scanned, or moved one onto a second spool.",
+                count=tags_kept,
+            )
+
+        await self._restore_spool_usage(db, usage_payload, tally, spool_id_map, archive_id_map)
+
+    async def _find_spool(self, db: AsyncSession, entry: dict) -> tuple[Spool | None, str | None]:
+        """Match a backed-up spool to a local row, and say which key matched.
+
+        Physical identity first (an RFID/Bambu tag is the spool), then a
+        descriptive composite including ``created_at`` so two otherwise
+        identical spools added at different times stay distinct.
+
+        The second element names the column that matched — ``"tag_uid"``,
+        ``"tray_uuid"`` or ``None`` for the composite. ``_guard_tag_overwrite``
+        needs it: the matched column holds the incoming value by definition, so
+        it is the *other* one that overwrite can corrupt.
+        """
+        tag_uid = entry.get("tag_uid")
+        if tag_uid:
+            result = await db.execute(select(Spool).where(Spool.tag_uid == tag_uid))
+            row = result.scalars().first()
+            if row is not None:
+                return row, "tag_uid"
+
+        tray_uuid = entry.get("tray_uuid")
+        if tray_uuid:
+            result = await db.execute(select(Spool).where(Spool.tray_uuid == tray_uuid))
+            row = result.scalars().first()
+            if row is not None:
+                return row, "tray_uuid"
+
+        created_at = _parse_dt(entry.get("created_at"))
+        if created_at is None:
+            return None, None
+        # created_at is filtered in Python, not here — see _created_at_matches.
+        result = await db.execute(
+            select(Spool).where(
+                Spool.material == (entry.get("material") or "PLA"),
+                Spool.brand == entry.get("brand"),
+                Spool.subtype == entry.get("subtype"),
+                Spool.color_name == entry.get("color_name"),
+            )
+        )
+        for row in result.scalars():
+            if _created_at_matches(row, created_at):
+                return row, None
+        return None, None
+
+    @staticmethod
+    async def _guard_tag_overwrite(db: AsyncSession, existing: Spool, fields: dict, matched_on: str | None) -> int:
+        """Remove tag columns from ``fields`` that an overwrite would corrupt.
+
+        ``tag_uid`` and ``tray_uuid`` are both in ``fields`` and overwrite is a
+        blanket ``setattr`` loop, so a spool matched on one key gets 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
+        ``.first()`` is non-deterministic and an AMS tag lookup resolves to an
+        arbitrary one of the two spools. The same loop can also *clear* a tag the
+        user has scanned since the backup was taken, when the backup entry holds
+        ``None``.
+
+        Two refusals, and the row is otherwise overwritten as normal:
+
+        * the incoming value is empty and the local row has one — the backup
+          predates the scan, so the local tag is the newer fact;
+        * the incoming value is already held by a different local spool — writing
+          it would create the duplicate described above.
+
+        Returns how many columns were left alone, so the caller can say so in the
+        tally rather than doing it silently.
+        """
+        kept = 0
+        for column in ("tag_uid", "tray_uuid"):
+            # The column we matched on already holds the incoming value.
+            if column == matched_on:
+                continue
+
+            incoming = fields.get(column)
+            current = getattr(existing, column)
+            if incoming == current:
+                continue
+
+            if not incoming:
+                if current:
+                    fields.pop(column)
+                    kept += 1
+                continue
+
+            clash = await db.execute(
+                select(Spool.id).where(getattr(Spool, column) == incoming, Spool.id != existing.id)
+            )
+            if clash.scalars().first() is not None:
+                fields.pop(column)
+                kept += 1
+        return kept
+
+    async def _restore_spool_usage(
+        self,
+        db: AsyncSession,
+        usage_payload,
+        tally: _CategoryTally,
+        spool_id_map: dict[int, int],
+        archive_id_map: dict[int, int],
+    ) -> None:
+        usage = usage_payload.get("usage_history") if isinstance(usage_payload, dict) else None
+        if not isinstance(usage, list) or not usage:
+            return
+
+        valid_printers = set((await db.execute(select(Printer.id))).scalars().all())
+        unresolved = 0
+        unlinked_archives = 0
+
+        for entry in usage:
+            if not isinstance(entry, dict):
+                tally.failed += 1
+                continue
+
+            old_spool_id = entry.get("spool_id")
+            spool_id = spool_id_map.get(old_spool_id) if isinstance(old_spool_id, int) else None
+            if spool_id is None:
+                # The parent spool never made it into the map: the backup's spool
+                # list didn't include it, or its entry carried no integer id. A
+                # spool that was merely *skipped* (matched locally, overwrite off)
+                # is mapped a few lines up in _restore_spools, so it never lands
+                # here — which is why the note below offers no remedy.
+                unresolved += 1
+                tally.skipped += 1
+                continue
+
+            created_at = _parse_dt(entry.get("created_at"))
+            # Usage history has no natural key of its own, so dedupe on the
+            # tuple that makes a consumption event unique in practice. As in
+            # _find_spool, created_at is compared in Python — see
+            # _created_at_matches. An entry carrying no created_at at all
+            # cannot be recognised and is re-inserted, which is what the
+            # IS NULL comparison this replaced did too: the column is
+            # non-nullable, so it never matched either.
+            existing = await db.execute(
+                select(SpoolUsageHistory).where(
+                    SpoolUsageHistory.spool_id == spool_id,
+                    SpoolUsageHistory.weight_used == (entry.get("weight_used") or 0),
+                    SpoolUsageHistory.print_name == entry.get("print_name"),
+                )
+            )
+            if any(_created_at_matches(row, created_at) for row in existing.scalars()):
+                tally.skipped += 1
+                continue
+
+            printer_id = entry.get("printer_id")
+            if printer_id is not None and printer_id not in valid_printers:
+                printer_id = None
+
+            old_archive_id = entry.get("archive_id")
+            archive_id = archive_id_map.get(old_archive_id) if isinstance(old_archive_id, int) else None
+            if archive_id is None and isinstance(old_archive_id, int):
+                # Restoring spools without archives leaves archive_id_map empty,
+                # so every "this print consumed that spool" link is dropped — the
+                # local archive may well exist, but its payload wasn't fetched,
+                # so there is no natural key here to match it on. Nor is it
+                # repairable by a later archives-only restore: the dedupe key
+                # above doesn't include archive_id, so these rows are recognised
+                # as already-present and skipped. Worth telling the user while
+                # they can still redo the run with both categories ticked.
+                unlinked_archives += 1
+
+            row = SpoolUsageHistory(
+                spool_id=spool_id,
+                printer_id=printer_id,
+                print_name=entry.get("print_name"),
+                archive_id=archive_id,
+                weight_used=entry.get("weight_used") or 0,
+                percent_used=entry.get("percent_used") or 0,
+                status=entry.get("status") or "completed",
+                cost=entry.get("cost"),
+            )
+            if created_at is not None:
+                row.created_at = created_at
+            db.add(row)
+            tally.restored += 1
+
+        if unresolved:
+            tally.note(
+                "spoolUsageUnresolved",
+                f"{unresolved} usage record(s) skipped — their spool is not in this backup's "
+                "spool list, so there is nothing to attach them to.",
+                count=unresolved,
+            )
+        if unlinked_archives:
+            tally.note(
+                "spoolUsageUnlinked",
+                f"{unlinked_archives} usage record(s) restored without their print-history link — "
+                "select Print archives alongside Spool inventory to keep it.",
+                count=unlinked_archives,
+            )
+
+    async def _restore_settings(
+        self,
+        db: AsyncSession,
+        payload,
+        overwrite: bool,
+        tally: _CategoryTally,
+        keys_written: set[str] | None = None,
+    ) -> None:
+        values = payload.get("settings") if isinstance(payload, dict) else None
+        if not isinstance(values, dict):
+            tally.note("noData", "No data of this kind in this backup")
+            return
+
+        # Planned before the first write, so the companion rule reads genuinely
+        # pre-restore local state, and so the preview and this run classify the
+        # payload identically.
+        plan = await self._plan_settings(db, values)
+        refused = plan.refused
+
+        for key, value in values.items():
+            if not isinstance(key, str) or not key:
+                tally.failed += 1
+                continue
+            if key in refused:
+                # Refusals are reported in the notes and nowhere else. They are
+                # already outside the preview's item count, and the preview is
+                # the number the user was shown, so counting them here would
+                # make restored + skipped + failed exceed it. The two skips
+                # below stay counted because they depend on this run's flags,
+                # which the preview cannot see.
+                continue
+            if value is None:
+                tally.skipped += 1
+                continue
+
+            result = await db.execute(select(Settings).where(Settings.key == key))
+            existing = result.scalar_one_or_none()
+            if existing is not None:
+                if not overwrite:
+                    tally.skipped += 1
+                    continue
+                existing.value = str(value)
+                tally.restored += 1
+                if keys_written is not None:
+                    keys_written.add(key)
+                continue
+
+            db.add(Settings(key=key, value=str(value)))
+            tally.restored += 1
+            if keys_written is not None:
+                keys_written.add(key)
+
+        if plan.blocked:
+            tally.note(
+                "settingsCredentialsSkipped",
+                f"{len(plan.blocked)} credential-like key(s) skipped — re-enter secrets manually",
+                count=len(plan.blocked),
+            )
+        if plan.protected:
+            tally.note(
+                "settingsAuthSkipped",
+                f"{len(plan.protected)} authentication setting(s) skipped — change those in Settings > "
+                "Authentication so the lockout checks still run",
+                count=len(plan.protected),
+            )
+        if plan.companion:
+            keys = ", ".join(sorted(plan.companion))
+            tally.note(
+                "settingsCompanionSkipped",
+                f"{keys} left switched off — the credential each one needs cannot be restored from a "
+                "backup and this instance has none stored, so switching them on would leave the "
+                "integration unauthenticated",
+                keys=keys,
+                count=len(plan.companion),
+            )
+
+    async def _reconfigure_mqtt_relay(self, db: AsyncSession, keys_written: set[str], tally: _CategoryTally) -> None:
+        """Push restored mqtt_* settings into the live relay.
+
+        The relay reads its broker config once, at configure() time — the
+        settings PUT handler reconfigures it for exactly this reason
+        (api/routes/settings.py). Writing the rows alone left the relay on the
+        pre-restore broker until the next backend restart while the UI showed
+        the restored values, which is the one way a restore could look applied
+        and not be.
+
+        Called after the commit, never before: configure() tears the connection
+        down and rebuilds it, so it must not run against values a later failure
+        could roll back. Only mqtt_password can't come back this way (the
+        credential blocklist skips it) — the row already in the database is
+        reused, so an unchanged broker keeps working.
+        """
+        if not _MQTT_SETTING_KEYS & keys_written:
+            return
+
+        try:
+            from backend.app.services.mqtt_relay import mqtt_relay
+
+            rows = await db.execute(select(Settings).where(Settings.key.in_(_MQTT_SETTING_KEYS)))
+            stored = {s.key: s.value for s in rows.scalars().all()}
+
+            # Same shape and defaults the settings PUT handler builds.
+            await mqtt_relay.configure(
+                {
+                    "mqtt_enabled": (stored.get("mqtt_enabled") or "false") == "true",
+                    "mqtt_broker": stored.get("mqtt_broker") or "",
+                    "mqtt_port": int(stored.get("mqtt_port") or "1883"),
+                    "mqtt_username": stored.get("mqtt_username") or "",
+                    "mqtt_password": stored.get("mqtt_password") or "",
+                    "mqtt_topic_prefix": stored.get("mqtt_topic_prefix") or "bambuddy",
+                    "mqtt_use_tls": (stored.get("mqtt_use_tls") or "false") == "true",
+                }
+            )
+        except Exception:
+            # Same call is best-effort in the settings PUT handler: the rows are
+            # committed either way, and a broker that refuses the new config
+            # must not turn a successful restore into a failed one. Noted rather
+            # than swallowed silently, so the user knows to restart.
+            logger.warning("Could not reconfigure the MQTT relay after a settings restore", exc_info=True)
+            tally.note(
+                "settingsMqttRelayFailed",
+                "MQTT settings restored, but the relay could not be reconnected — restart Bambuddy",
+            )
+
+    async def _restore_kprofiles(self, db: AsyncSession, payload: dict, tally: _CategoryTally) -> None:
+        by_serial: dict[str, list[tuple[str, dict]]] = {}
+        for path, content in payload.items():
+            match = _KPROFILE_PATH_RE.match(path)
+            if not match or not isinstance(content, dict):
+                continue
+            by_serial.setdefault(match.group(1), []).append((match.group(2), content))
+
+        if not by_serial:
+            tally.note("noData", "No data of this kind in this backup")
+            return
+
+        result = await db.execute(select(Printer))
+        printers = {p.serial_number: p for p in result.scalars().all() if p.serial_number}
+
+        # Overwrite is not offered for K-profiles: extrusion_cali_set replaces
+        # the profile occupying a slot, so writing is always an overwrite on the
+        # printer side.
+        tally.note("kprofilesAlwaysOverwrite", "K-profiles always overwrite the matching slot on the printer")
+        # A refusal is now believed and counted failed (#2718 made the ack worth
+        # reading), but silence still counts restored, so the caveat stands —
+        # narrowed to what is actually left uncertain.
+        tally.note(
+            "kprofilesAckUnreliable",
+            "A printer that does not answer still counts as restored — verify the profiles on the printer",
+        )
+
+        for serial, entries in sorted(by_serial.items()):
+            profile_total = self._kprofile_profile_count(c for _, c in entries)
+
+            printer = printers.get(serial)
+            if printer is None:
+                tally.skipped += profile_total
+                tally.note("kprofilesPrinterMissing", f"No printer with serial {serial} — skipped", serial=serial)
+                continue
+
+            client = printer_manager.get_client(printer.id)
+            if not client or not client.state.connected:
+                tally.skipped += profile_total
+                tally.note(
+                    "kprofilesPrinterOffline",
+                    f"{printer.name} ({serial}) is not connected — skipped",
+                    printer=printer.name,
+                    serial=serial,
+                )
+                continue
+
+            for nozzle, content in sorted(entries):
+                profiles = content.get("profiles")
+                if not isinstance(profiles, list) or not profiles:
+                    continue
+                if nozzle not in _KNOWN_NOZZLES:
+                    tally.note(
+                        "kprofilesUnknownNozzle",
+                        f"Unexpected nozzle diameter {nozzle} for {serial} — sent as-is",
+                        nozzle=nozzle,
+                        serial=serial,
+                    )
+
+                # The backup's slot_id is a cali_idx, and cali_idx is as
+                # unstable as the autoincrement ids we already refuse to reuse
+                # for spools and archives: editing a profile in Bambuddy is a
+                # delete-then-add on a single-nozzle printer, which re-keys it.
+                # Addressing extrusion_cali_set at a slot that no longer exists
+                # is a silent no-op — the printer drops it and we would still
+                # report the profile restored. So resolve the live index first.
+                current = await self._current_kprofile_index(client, nozzle, serial)
+
+                profile_dicts = []
+                unmatched = 0
+                # A live profile can only stand in for one backed-up entry. Two
+                # entries resolving to the same cali_idx both go into the batch,
+                # the second overwrites the first on the printer, and the tally
+                # counts two restored where one landed.
+                claimed: set[int] = set()
+                for p in profiles:
+                    if not isinstance(p, dict):
+                        # Counted, not dropped. _kprofile_profile_count includes
+                        # it, so the offline and printer-missing paths already
+                        # count the same entry skipped and the failure path
+                        # counts it outstanding — leaving the tally here was the
+                        # one place a profile could vanish from
+                        # restored + skipped + failed entirely.
+                        #
+                        # failed here against skipped there is not a
+                        # disagreement about the entry. The three counters say
+                        # what happened to an item on this run, not whether it
+                        # was ever usable: an offline printer skips everything it
+                        # holds, well-formed or not, because nothing was
+                        # attempted, while here the entry was reached and could
+                        # not be used.
+                        tally.failed += 1
+                        continue
+                    match = self._match_kprofile(p, current, claimed)
+                    if match is None:
+                        unmatched += 1
+                    else:
+                        claimed.add(match.slot_id)
+                    entry = {
+                        "filament_id": p.get("filament_id", ""),
+                        "name": p.get("name", ""),
+                        "k_value": p.get("k_value", "0.020000"),
+                        "extruder_id": p.get("extruder_id", 0),
+                        # Prefer the live setting_id when we matched: it is
+                        # what the printer currently associates with the slot.
+                        "setting_id": (match.setting_id if match else None) or p.get("setting_id"),
+                        # cali_idx -1 tells the printer to add a new profile
+                        # rather than address a slot that isn't there.
+                        "cali_idx": match.slot_id if match else -1,
+                        # Only consulted for the generated-setting_id
+                        # fallback; cali_idx above takes precedence.
+                        "slot_id": 0,
+                    }
+
+                    # Same precedence as setting_id, and set only when known.
+                    # nozzle_id encodes the fitted nozzle's type and diameter
+                    # ("HS00-0.4"), so the live value beats the backup's: the
+                    # user may have swapped the nozzle since. When neither knows,
+                    # the key has to be *absent* — set_kprofiles_batch supplies
+                    # HS00-{diameter} via p.get(..., default), which a key
+                    # present-and-None defeats, publishing a null nozzle_id.
+                    # Printers that omit it are the reason the default is there
+                    # (#1748), so it has to be reachable.
+                    nozzle_id = (getattr(match, "nozzle_id", None) if match else None) or p.get("nozzle_id")
+                    if nozzle_id:
+                        entry["nozzle_id"] = nozzle_id
+                    profile_dicts.append(entry)
+                if not profile_dicts:
+                    continue
+                if unmatched:
+                    tally.note(
+                        "kprofilesUnmatched",
+                        f"{unmatched} profile(s) for {nozzle} had no counterpart on {printer.name} "
+                        "— added as new profiles",
+                        count=unmatched,
+                        nozzle=nozzle,
+                        printer=printer.name,
+                    )
+
+                try:
+                    seq = client.set_kprofiles_batch(profile_dicts, nozzle)
+                except Exception as e:
+                    logger.warning("K-profile restore failed for %s nozzle %s: %s", serial, nozzle, e)
+                    seq = None
+
+                if not seq:
+                    tally.failed += len(profile_dicts)
+                    tally.note(
+                        "kprofilesSendFailed",
+                        f"Failed to send {nozzle} profiles to {printer.name} ({serial})",
+                        nozzle=nozzle,
+                        printer=printer.name,
+                        serial=serial,
+                    )
+                    continue
+
+                # What came back is the sequence_id the command was published
+                # under, not a verdict (#2718) — a truthy string only means the
+                # command left the building. The printer answers separately, and
+                # every other caller of this API now reads that answer; without
+                # this the restore would be the one path left that reports a
+                # refused write as saved.
+                ok, detail = await self._kprofile_ack(client, seq, serial, nozzle)
+                if ok:
+                    tally.restored += len(profile_dicts)
+                else:
+                    tally.failed += len(profile_dicts)
+                    tally.note(
+                        "kprofilesRefused",
+                        f"{printer.name} ({serial}) refused the {nozzle} profiles: {detail}",
+                        nozzle=nozzle,
+                        printer=printer.name,
+                        serial=serial,
+                        reason=detail,
+                    )
+
+    @staticmethod
+    def _kprofile_profile_count(contents) -> int:
+        """Count the profiles across parsed K-profile files.
+
+        Defensive on purpose. A hand-edited or truncated backup can carry a
+        ``profiles`` value that is not a list, and this count runs *before* the
+        per-call guards in the loop below — after ``_apply`` has already
+        committed the database categories. A malformed file has to be a skipped
+        category, not an exception thrown over committed rows.
+        """
+        total = 0
+        for content in contents:
+            profiles = content.get("profiles") if isinstance(content, dict) else None
+            if isinstance(profiles, list):
+                total += len(profiles)
+        return total
+
+    @staticmethod
+    async def _kprofile_ack(client, seq: str, serial: str, nozzle: str) -> tuple[bool, str]:
+        """Read the printer's verdict on one batch write.
+
+        ``await_cali_ack`` already treats silence as success — no answer is not
+        evidence of refusal, and firmware that predates the ack never answers at
+        all. An exception reading it is the same situation one layer up, so it
+        degrades the same way rather than turning a write that most likely
+        landed into a reported failure.
+        """
+        try:
+            ok, detail = await client.await_cali_ack(seq)
+            return bool(ok), str(detail or "")
+        except Exception as e:
+            logger.warning("Could not read the K-profile ack for %s nozzle %s: %s", serial, nozzle, e)
+            return True, ""
+
+    @staticmethod
+    async def _current_kprofile_index(client, nozzle: str, serial: str) -> list:
+        """Read the printer's live profiles for one nozzle.
+
+        Best-effort: a read failure degrades to "nothing matched", which makes
+        every profile an add rather than aborting the restore.
+        """
+        try:
+            return list(await client.get_kprofiles(nozzle_diameter=nozzle) or [])
+        except Exception as e:
+            logger.warning("Could not read live K-profiles for %s nozzle %s: %s", serial, nozzle, e)
+            return []
+
+    @staticmethod
+    def _match_kprofile(entry: dict, current: list, claimed: set[int]):
+        """Find the live profile a backed-up entry corresponds to.
+
+        ``setting_id`` is the filament preset the profile was calibrated for and
+        is the strongest signal; a delete-then-add edit regenerates it, so fall
+        back to the display name, which Bambuddy's own editor preserves.
+        Both are scoped by ``filament_id`` — the same preset on a different
+        filament is a different profile — and by ``extruder_id``, because on a
+        dual-nozzle printer the same preset on the other extruder is a different
+        profile too.
+
+        ``claimed`` holds the slot ids already taken by earlier entries in this
+        nozzle's loop, and no live profile may be claimed twice. Without it, two
+        backed-up entries sharing a ``filament_id`` and matching on neither
+        ``setting_id`` nor ``name`` both fell through to the single-candidate
+        arm and both took the same slot — reachable whenever the user has since
+        deleted one of a pair, because the delete-then-add re-key is what strips
+        the ``setting_id`` match. Returning None for the displaced entry means
+        ``cali_idx: -1``, i.e. add-as-new, which is the safe outcome.
+        """
+        filament_id = entry.get("filament_id")
+        if not filament_id:
+            return None
+
+        candidates = [c for c in current if c.filament_id == filament_id]
+
+        # The live index is read per nozzle *diameter*, so on an H2D both
+        # extruders' profiles come back together. With the same filament
+        # calibrated on both — the ordinary case on a dual-nozzle printer, not an
+        # exotic one — filament_id alone lets extruder 0's backed-up entry match
+        # extruder 1's live profile, and the batch then carries
+        # {extruder_id: 0, cali_idx: <extruder-1 slot>}: one extruder's
+        # calibration written over the other's, counted restored.
+        #
+        # Conditional on both sides saying which extruder they mean. A pre-#2656
+        # backup carries no extruder_id, and a live index that reports none must
+        # not turn every entry into an add.
+        extruder_id = entry.get("extruder_id")
+        if isinstance(extruder_id, int) and any(getattr(c, "extruder_id", None) is not None for c in candidates):
+            candidates = [c for c in candidates if getattr(c, "extruder_id", None) == extruder_id]
+
+        available = [c for c in candidates if c.slot_id not in claimed]
+        if not available:
+            return None
+
+        setting_id = entry.get("setting_id")
+        if setting_id:
+            for c in available:
+                if c.setting_id == setting_id:
+                    return c
+
+        name = entry.get("name")
+        if name:
+            for c in available:
+                if c.name == name:
+                    return c
+
+        # Exactly one profile for this filament and no better discriminator:
+        # treat it as the same profile rather than duplicating it. Judged
+        # against every candidate rather than the unclaimed ones, because two
+        # live profiles for one filament are ambiguous whether or not another
+        # entry has already taken one of them.
+        return available[0] if len(candidates) == 1 else None
+
+
+# Singleton instance
+github_restore_service = GitHubRestoreService()

+ 271 - 0
backend/app/services/ha_sensor_manager.py

@@ -0,0 +1,271 @@
+"""Polls the Home Assistant entities bound to printers (#1148, #448).
+
+One background loop reads every configured entity on a fixed cadence and keeps
+the result in memory. Three things consume it:
+
+* the printer card, which reads the cache instead of hitting Home Assistant
+  once per card per refresh;
+* notifications, fired on a transition *into* the alert state, never on every
+  poll while it persists;
+* the print interlock, which holds queued jobs for a printer while one of its
+  sensors is alerting.
+
+Everything degrades to "no opinion" when Home Assistant cannot be reached: an
+unreadable sensor never alerts, never notifies, and never holds a print. A
+door contact that stops responding must not strand the queue.
+"""
+
+import asyncio
+import logging
+from dataclasses import dataclass
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.printer import Printer
+from backend.app.models.printer_ha_sensor import PrinterHASensor
+from backend.app.services.homeassistant import as_float, homeassistant_service
+from backend.app.utils.local_time import utcnow_naive
+
+logger = logging.getLogger(__name__)
+
+# Fast enough that an enclosure door reads as live, slow enough that a handful
+# of tiny LAN requests stays background noise.
+POLL_INTERVAL = 15
+
+
+@dataclass
+class SensorReading:
+    """The last thing we managed to read for one sensor."""
+
+    state: str | None  # raw HA state, None when unreadable
+    value: float | None  # parsed number for numeric sensors
+    alerting: bool
+    reachable: bool
+
+
+class HASensorManager:
+    def __init__(self):
+        self._task: asyncio.Task | None = None
+        # sensor id -> last reading. Sensors absent from this map have not been
+        # polled yet; callers must not read that as "not alerting" without also
+        # checking, which is why get_reading returns None rather than a default.
+        self._readings: dict[int, SensorReading] = {}
+        # sensor id -> alerting, from the last reading we could actually take.
+        # Kept apart from _readings because a dropout must not read as the
+        # alert clearing: on -> unavailable -> on is one continuous alert, and
+        # notifying off _readings alone would ping the user on every reconnect
+        # of a flaky contact. Absent means "never had a reachable reading".
+        self._last_alerting: dict[int, bool] = {}
+
+    # -- lifecycle ---------------------------------------------------------
+
+    def start(self):
+        if self._task is None:
+            self._task = asyncio.create_task(self._poll_loop())
+            logger.info("Home Assistant sensor poller started")
+
+    def stop(self):
+        if self._task:
+            self._task.cancel()
+            self._task = None
+            logger.info("Home Assistant sensor poller stopped")
+
+    # -- cache access ------------------------------------------------------
+
+    def get_reading(self, sensor_id: int) -> SensorReading | None:
+        return self._readings.get(sensor_id)
+
+    def forget(self, sensor_id: int):
+        """Drop a deleted sensor's cached reading so its id cannot be reused
+        by a later row and answer with the old sensor's state."""
+        self._readings.pop(sensor_id, None)
+        self._last_alerting.pop(sensor_id, None)
+
+    async def blocked_printers(self, db: AsyncSession) -> dict[int, str]:
+        """Printers currently held by an interlock, mapped to the sensor names.
+
+        A sensor counts only when it is configured to block, *and* was read
+        successfully, *and* is in its alert state. Anything we could not read
+        is omitted, so the queue keeps moving when Home Assistant is down.
+
+        One query for the whole fleet — the scheduler calls this on every pass,
+        and per-printer lookups would put a query per printer in that loop.
+        """
+        result = await db.execute(select(PrinterHASensor).where(PrinterHASensor.block_print.is_(True)))
+        blocked: dict[int, list[str]] = {}
+        for sensor in result.scalars().all():
+            reading = self._readings.get(sensor.id)
+            if reading and reading.reachable and reading.alerting:
+                blocked.setdefault(sensor.printer_id, []).append(sensor.name)
+        return {printer_id: ", ".join(names) for printer_id, names in blocked.items()}
+
+    # -- polling -----------------------------------------------------------
+
+    async def _poll_loop(self):
+        while True:
+            try:
+                await asyncio.sleep(POLL_INTERVAL)
+                await self.poll_once()
+            except asyncio.CancelledError:
+                break
+            except Exception as e:
+                logger.warning("Home Assistant sensor poll failed: %s", e)
+
+    async def poll_once(self):
+        """One pass over every configured sensor."""
+        from backend.app.core.database import async_session
+
+        async with async_session() as db:
+            result = await db.execute(select(PrinterHASensor))
+            sensors = list(result.scalars().all())
+
+            # Drop readings for rows that no longer exist. The delete route
+            # calls forget(), but a printer deleted with sensors attached takes
+            # them out by cascade, and a restored backup can renumber them —
+            # either way a stale id must not answer for a later sensor.
+            live = {s.id for s in sensors}
+            for stale in set(self._readings) - live:
+                self.forget(stale)
+
+            if not sensors:
+                return
+
+            if not await self._configure(db):
+                # Not configured is not a failure to report every 15 seconds,
+                # but the readings must not go stale-but-confident either.
+                for sensor in sensors:
+                    self._readings[sensor.id] = SensorReading(None, None, False, False)
+                return
+
+            states = await homeassistant_service.fetch_states(sorted({s.entity_id for s in sensors}))
+            await self._apply(db, sensors, states)
+
+    async def refresh_one(self, db: AsyncSession, sensor: PrinterHASensor):
+        """Read a single sensor now, on the caller's session.
+
+        Used after a create or an edit so the card shows a state straight away
+        instead of blank until the next tick. Deliberately not a full
+        ``poll_once``: a request handler must not wait on every configured
+        entity, and must not fire another user's notification as a side effect
+        of this one saving a form.
+        """
+        self.forget(sensor.id)
+        if not await self._configure(db):
+            self._readings[sensor.id] = SensorReading(None, None, False, False)
+            return
+
+        states = await homeassistant_service.fetch_states([sensor.entity_id])
+        reading = evaluate(sensor, states.get(sensor.entity_id))
+        self._readings[sensor.id] = reading
+        if reading.reachable:
+            self._last_alerting[sensor.id] = reading.alerting
+
+        sensor.last_checked = utcnow_naive()
+        if reading.reachable and sensor.last_state != reading.state:
+            sensor.last_state = reading.state
+            sensor.last_changed = sensor.last_checked
+        await db.commit()
+        await db.refresh(sensor)
+
+    async def _configure(self, db: AsyncSession) -> bool:
+        from backend.app.api.routes.settings import get_homeassistant_settings
+
+        try:
+            ha_settings = await get_homeassistant_settings(db)
+        except Exception as e:
+            logger.warning("Failed to read Home Assistant settings: %s", e)
+            return False
+        if not ha_settings["ha_url"] or not ha_settings["ha_token"]:
+            return False
+        homeassistant_service.configure(ha_settings["ha_url"], ha_settings["ha_token"])
+        return True
+
+    async def _apply(self, db: AsyncSession, sensors: list[PrinterHASensor], states: dict[str, dict | None]):
+        """Fold poll results into the cache, the DB and any notifications."""
+        from backend.app.services.notification_service import notification_service
+
+        now = utcnow_naive()
+        alerts: list[tuple[PrinterHASensor, SensorReading]] = []
+
+        for sensor in sensors:
+            payload = states.get(sensor.entity_id)
+            reading = evaluate(sensor, payload)
+            was_alerting = self._last_alerting.get(sensor.id)
+            self._readings[sensor.id] = reading
+
+            sensor.last_checked = now
+            if reading.reachable:
+                if sensor.last_state != reading.state:
+                    sensor.last_state = reading.state
+                    sensor.last_changed = now
+
+            # Notify on the edge into alerting only. `was_alerting is None` is
+            # a cold cache (first poll after a restart) — a door that was
+            # already open then has not just been opened, and re-announcing it
+            # on every restart would train users to ignore the alert.
+            if sensor.notify_on_alert and reading.reachable and reading.alerting and was_alerting is False:
+                alerts.append((sensor, reading))
+
+            if reading.reachable:
+                self._last_alerting[sensor.id] = reading.alerting
+
+        await db.commit()
+
+        for sensor, reading in alerts:
+            # db.get, not sensor.printer: touching the lazy relationship from
+            # an async session raises MissingGreenlet.
+            printer = await db.get(Printer, sensor.printer_id)
+            try:
+                await notification_service.on_ha_sensor_alert(
+                    printer_id=sensor.printer_id,
+                    printer_name=printer.name if printer else "Unknown",
+                    sensor_name=sensor.name,
+                    state=describe_state(sensor, reading),
+                    db=db,
+                )
+            except Exception as e:
+                logger.warning("Failed to send HA sensor alert for '%s': %s", sensor.name, e)
+
+
+def evaluate(sensor: PrinterHASensor, payload: dict | None) -> SensorReading:
+    """Turn one HA state payload into a reading.
+
+    Split out from the manager so the alert rules can be tested without a
+    poller, a database or a Home Assistant.
+    """
+    if payload is None:
+        return SensorReading(state=None, value=None, alerting=False, reachable=False)
+
+    state = payload.get("state")
+    # HA reports these two for entities whose integration is down. Treating
+    # them as a state would make "unavailable" a value the card renders and
+    # the thresholds compare against.
+    if state in (None, "unknown", "unavailable"):
+        return SensorReading(state=None, value=None, alerting=False, reachable=False)
+
+    state = str(state)
+    if sensor.kind == "numeric":
+        value = as_float(state)
+        if value is None:
+            # A sensor that used to report numbers and now reports text is
+            # not a reading we can place against a threshold.
+            return SensorReading(state=state, value=None, alerting=False, reachable=True)
+        alerting = (sensor.alert_above is not None and value > sensor.alert_above) or (
+            sensor.alert_below is not None and value < sensor.alert_below
+        )
+        return SensorReading(state=state, value=value, alerting=alerting, reachable=True)
+
+    normalized = state.lower()
+    alerting = sensor.alert_state is not None and normalized == sensor.alert_state
+    return SensorReading(state=normalized, value=None, alerting=alerting, reachable=True)
+
+
+def describe_state(sensor: PrinterHASensor, reading: SensorReading) -> str:
+    """Human-readable state for a notification body ("open", "31.4 °C")."""
+    if sensor.kind == "numeric" and reading.value is not None:
+        return f"{reading.value:g} {sensor.unit}".strip() if sensor.unit else f"{reading.value:g}"
+    return reading.state or "unknown"
+
+
+ha_sensor_manager = HASensorManager()

+ 102 - 0
backend/app/services/homeassistant.py

@@ -1,5 +1,6 @@
 """Service for communicating with Home Assistant via REST API."""
 
+import asyncio
 import logging
 from typing import TYPE_CHECKING
 from urllib.parse import urlparse
@@ -364,6 +365,107 @@ class HomeAssistantService:
             logger.warning("Failed to list HA sensor entities: %s", e)
             return []
 
+    async def list_display_entities(self, url: str, token: str, search: str | None = None) -> list[dict]:
+        """List entities that can be bound to a printer for display (#1148, #448).
+
+        Covers every ``binary_sensor.*`` plus the ``sensor.*`` entities that
+        carry a reading. Distinct from ``list_sensor_entities``, which exists
+        for a plug's energy monitoring and therefore only admits power/energy
+        units — an enclosure thermometer is exactly what that one filters out.
+
+        A ``sensor.*`` qualifies when it has a unit or its state parses as a
+        number. That drops the text sensors (``sensor.washing_machine_status``)
+        that the card has no way to render as a value.
+        """
+        try:
+            async with httpx.AsyncClient(timeout=self.timeout) as client:
+                response = await client.get(
+                    f"{url.rstrip('/')}/api/states",
+                    headers={"Authorization": f"Bearer {token}"},
+                )
+                response.raise_for_status()
+
+                entities = []
+                search_lower = search.lower().strip() if search else None
+
+                for entity in response.json():
+                    entity_id = entity.get("entity_id", "")
+                    domain = entity_id.split(".")[0] if "." in entity_id else ""
+                    if domain not in ("binary_sensor", "sensor"):
+                        continue
+
+                    attrs = entity.get("attributes", {})
+                    unit = attrs.get("unit_of_measurement")
+                    state = entity.get("state")
+
+                    if domain == "sensor" and not unit and as_float(state) is None:
+                        continue
+
+                    friendly_name = attrs.get("friendly_name") or entity_id
+                    if search_lower and (
+                        search_lower not in entity_id.lower() and search_lower not in friendly_name.lower()
+                    ):
+                        continue
+
+                    entities.append(
+                        {
+                            "entity_id": entity_id,
+                            "friendly_name": friendly_name,
+                            "state": state,
+                            "domain": domain,
+                            "device_class": attrs.get("device_class"),
+                            "unit_of_measurement": unit,
+                        }
+                    )
+
+                return sorted(entities, key=lambda x: x["friendly_name"].lower())
+        except Exception as e:
+            logger.warning("Failed to list HA display entities: %s", e)
+            return []
+
+    async def fetch_states(self, entity_ids: list[str]) -> dict[str, dict | None]:
+        """Read several entities in one pass, keyed by entity_id.
+
+        One GET per entity over a shared client rather than a single
+        ``/api/states`` sweep: the poller only ever wants a handful of bound
+        entities, and pulling every state in the user's Home Assistant on a
+        15-second cadence is a lot of payload to throw away.
+
+        A ``None`` value means that entity could not be read — the callers
+        treat that as "no opinion" rather than as a state, so an unreachable
+        Home Assistant never trips an alert or holds a print.
+        """
+        if not entity_ids:
+            return {}
+        if not self.base_url or not self.token:
+            return dict.fromkeys(entity_ids)
+
+        async with httpx.AsyncClient(timeout=self.timeout) as client:
+
+            async def _one(entity_id: str) -> tuple[str, dict | None]:
+                try:
+                    response = await client.get(
+                        f"{self.base_url}/api/states/{entity_id}",
+                        headers=self._headers(),
+                    )
+                    response.raise_for_status()
+                    return entity_id, response.json()
+                except Exception as e:
+                    logger.debug("Failed to read HA entity %s: %s", entity_id, e)
+                    return entity_id, None
+
+            results = await asyncio.gather(*(_one(e) for e in entity_ids))
+
+        return dict(results)
+
+
+def as_float(value) -> float | None:
+    """Parse a HA state to a number, or None for "unknown"/"unavailable"/text."""
+    try:
+        return float(value)
+    except (TypeError, ValueError):
+        return None
+
 
 # Singleton instance
 homeassistant_service = HomeAssistantService()

+ 42 - 18
backend/app/services/ldap_service.py

@@ -14,6 +14,7 @@ import logging
 from dataclasses import dataclass
 
 from ldap3 import ALL, SUBTREE, Connection, Server, Tls
+from ldap3.core.exceptions import LDAPObjectClassError
 
 logger = logging.getLogger(__name__)
 
@@ -155,32 +156,55 @@ def _extract_user_info(
 
     canonical_username = _pick_canonical_username(user_entry, fallback_username)
 
-    # Also search for POSIX groups (memberUid-based) using the service account
-    posix_filter = f"(&(objectClass=posixGroup)(memberUid={_ldap_escape(canonical_username)}))"
-    service_conn.search(
-        search_base=config.search_base,
-        search_filter=posix_filter,
-        search_scope=SUBTREE,
-        attributes=["cn"],
-    )
-    for entry in service_conn.entries:
-        groups.append(str(entry.entry_dn))
-
-    # POSIX primary group: user's gidNumber matches a posixGroup's gidNumber.
-    # Standard Unix semantics treat this as full group membership, so we need
-    # to resolve it to a group DN alongside the memberUid results.
-    if hasattr(user_entry, "gidNumber") and user_entry.gidNumber:
-        primary_gid = str(user_entry.gidNumber)
-        primary_filter = f"(&(objectClass=posixGroup)(gidNumber={_ldap_escape(primary_gid)}))"
+    # Also search for POSIX groups, both the memberUid kind and the primary
+    # gidNumber kind. Both filters name the posixGroup object class, and ldap3
+    # validates that name against the schema it fetched at connect time
+    # (get_info=ALL) before it builds the request — so on a directory that
+    # publishes a schema without posixGroup it raises client-side and nothing is
+    # ever sent. A directory with no posixGroup class has no posixGroup entries,
+    # which is exactly the answer the searches would have returned, so the
+    # correct response is to carry on with the memberOf groups collected above.
+    #
+    # Left uncaught, that exception escaped authenticate_ldap_user, and the login
+    # route reports any LDAP error as "Incorrect username or password" — so an
+    # lldap user, whose accounts carry posixAccount but whose directory defines
+    # no group classes beyond groupOfNames, could never log in and had nothing
+    # but a wrong-password message to go on (#2769). This predates the primary
+    # gidNumber lookup: the memberUid filter has named the class since #794.
+    try:
+        posix_filter = f"(&(objectClass=posixGroup)(memberUid={_ldap_escape(canonical_username)}))"
         service_conn.search(
             search_base=config.search_base,
-            search_filter=primary_filter,
+            search_filter=posix_filter,
             search_scope=SUBTREE,
             attributes=["cn"],
         )
         for entry in service_conn.entries:
             groups.append(str(entry.entry_dn))
 
+        # POSIX primary group: user's gidNumber matches a posixGroup's gidNumber.
+        # Standard Unix semantics treat this as full group membership, so we need
+        # to resolve it to a group DN alongside the memberUid results.
+        if hasattr(user_entry, "gidNumber") and user_entry.gidNumber:
+            primary_gid = str(user_entry.gidNumber)
+            primary_filter = f"(&(objectClass=posixGroup)(gidNumber={_ldap_escape(primary_gid)}))"
+            service_conn.search(
+                search_base=config.search_base,
+                search_filter=primary_filter,
+                search_scope=SUBTREE,
+                attributes=["cn"],
+            )
+            for entry in service_conn.entries:
+                groups.append(str(entry.entry_dn))
+    except LDAPObjectClassError:
+        # Logged once per authentication, at info: it is the explanation for a
+        # user's POSIX groups being absent from their mapping, and it is not an
+        # error the operator can or should act on.
+        logger.info(
+            "Directory publishes no posixGroup object class; skipping POSIX group lookup "
+            "(memberOf groups are unaffected)"
+        )
+
     # Dedupe group DNs (user may be in a group via both memberUid and primary gidNumber).
     # Case-insensitive comparison — LDAP DNs are case-insensitive by spec.
     seen_lower: set[str] = set()

+ 97 - 0
backend/app/services/library_trash.py

@@ -27,7 +27,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import async_session
 from backend.app.models.library import LibraryFile
+from backend.app.models.print_queue import PrintQueueItem, PrintQueueVariant
 from backend.app.models.settings import Settings
+from backend.app.utils.local_time import utcnow_naive
 
 logger = logging.getLogger(__name__)
 
@@ -351,6 +353,8 @@ class LibraryTrashService:
         for row in rows:
             self._unlink_on_disk(row)
             deleted += 1
+        await delete_dependent_variants(db, [r.id for r in rows])
+        await release_queue_references(db, [r.id for r in rows])
         # Single DELETE is faster than N await db.delete() round-trips; we
         # still need the Python loop above to unlink bytes on disk.
         await db.execute(delete(LibraryFile).where(LibraryFile.id.in_([r.id for r in rows])))
@@ -383,8 +387,101 @@ class LibraryTrashService:
     async def hard_delete_now(self, db: AsyncSession, file: LibraryFile) -> None:
         """Bypass retention and delete this trashed file + its bytes immediately."""
         self._unlink_on_disk(file)
+        await delete_dependent_variants(db, [file.id])
+        await release_queue_references(db, [file.id])
         await db.delete(file)
         await db.commit()
 
 
+async def release_queue_references(db: AsyncSession, file_ids: list[int]) -> int:
+    """Take queued work off files that are about to be hard-deleted (#2819).
+
+    Call this before any statement that removes ``library_files`` rows — the
+    plain deletes in the routes, the folder cascade, and the sweeper. It is the
+    same repair the scheduler does when a dispatch consumes its own library row
+    (``_repoint_siblings_at_archive``), minus the part that cannot apply here:
+    nothing is being printed, so there is no archive to hand the work to.
+
+    Two things happen, and both matter on a different database:
+
+    * Items still waiting on one of these files are cancelled, saying which file
+      went. Without it a queued job sat there looking dispatchable and failed at
+      the printer with "Library file not found", days later and with nothing
+      naming the delete that caused it.
+    * Every remaining row referencing the file has ``library_file_id`` cleared.
+      That is what keeps it: ``print_queue.library_file_id`` is ``ON DELETE
+      CASCADE``, which SQLite does not enforce and PostgreSQL does, so those rows
+      were silently deleted there -- including finished ones, which is what a
+      batch order counts its progress from.
+
+    Rows already printing are left in place. One of those is a job on a machine
+    right now; the file being deleted is the copy in the library, not the copy
+    the printer is working from. Returns the number of items cancelled.
+    """
+    if not file_ids:
+        return 0
+
+    doomed: dict[int, list[int]] = {}
+    rows = (
+        await db.execute(
+            select(PrintQueueItem.id, PrintQueueItem.library_file_id)
+            .where(PrintQueueItem.library_file_id.in_(file_ids))
+            .where(PrintQueueItem.archive_id.is_(None))
+            # "skipped" is not terminal: clearing a printer's previous-success
+            # gate puts those items back to pending, onto a file that by then
+            # is gone.
+            .where(PrintQueueItem.status.in_(("pending", "skipped")))
+        )
+    ).all()
+    for item_id, lib_id in rows:
+        doomed.setdefault(lib_id, []).append(item_id)
+
+    if doomed:
+        names = dict(
+            (
+                await db.execute(select(LibraryFile.id, LibraryFile.filename).where(LibraryFile.id.in_(list(doomed))))
+            ).all()
+        )
+        # Naive UTC: `completed_at` is a naive column, and asyncpg rejects an
+        # aware value outright where SQLite silently drops the offset.
+        now = utcnow_naive()
+        # One statement per file rather than per item: the case this exists for
+        # is many copies of one file, and a folder delete can reach a lot of
+        # them at once.
+        for lib_id, item_ids in doomed.items():
+            await db.execute(
+                PrintQueueItem.__table__.update()
+                .where(PrintQueueItem.id.in_(item_ids))
+                .values(
+                    status="cancelled",
+                    completed_at=now,
+                    error_message=f"'{names.get(lib_id, 'The library file')}' was deleted from the library",
+                )
+            )
+        logger.info("Library delete: cancelled %d queued item(s) whose file was removed", len(rows))
+
+    await db.execute(
+        PrintQueueItem.__table__.update()
+        .where(PrintQueueItem.library_file_id.in_(file_ids))
+        .values(library_file_id=None)
+    )
+    return len(rows)
+
+
+async def delete_dependent_variants(db: AsyncSession, file_ids: list[int]) -> None:
+    """Drop cross-model queue candidates that pointed at these files (#671).
+
+    SQLite ships with ``PRAGMA foreign_keys`` off — verified, not assumed — so
+    the ON DELETE CASCADE on ``print_queue_variants.library_file_id`` never fires
+    on the default deployment and the rows would outlive the file.
+
+    The scheduler already refuses to dispatch a candidate whose file is missing
+    or trashed, so nothing prints wrongly without this. It is here so the table
+    does not fill with rows referencing files that no longer exist.
+    """
+    if not file_ids:
+        return
+    await db.execute(delete(PrintQueueVariant).where(PrintQueueVariant.library_file_id.in_(file_ids)))
+
+
 library_trash_service = LibraryTrashService()

+ 11 - 0
backend/app/services/log_health.py

@@ -123,6 +123,17 @@ SIGNATURES: tuple[LogSignature, ...] = (
         logger_prefix="backend.app.services.camera",
         min_count=3,
     ),
+    LogSignature(
+        # Bambu's anti-abuse layer is challenging this network with a CAPTCHA,
+        # so no Bambu Cloud sign-in can complete. Nothing in the install is
+        # broken and no credential will help — see bambu_cloud.is_captcha_challenge.
+        id="bambu-cloud-captcha",
+        patterns=_compile(r"challenging this network with a CAPTCHA"),
+        severity="warning",
+        category="environment",
+        wiki_anchor="bambu-cloud-captcha",
+        logger_prefix="backend.app.services.bambu_cloud",
+    ),
     LogSignature(
         # SQLite write contention. Surfaces inside exception tracebacks; folded
         # continuation lines are part of the entry message, so this still

+ 18 - 12
backend/app/services/makerworld.py

@@ -28,7 +28,7 @@ from urllib.parse import urlparse
 import certifi
 import httpx
 
-from backend.app.services.bambu_cloud import is_expiry_401
+from backend.app.services.bambu_cloud import is_captcha_challenge, is_expiry_401
 
 logger = logging.getLogger(__name__)
 
@@ -331,18 +331,24 @@ class MakerWorldService:
         if response.status_code == 404:
             raise MakerWorldNotFoundError(f"MakerWorld resource not found: {path}")
         if response.status_code == 418:
-            # MakerWorld's anti-abuse layer challenges the source IP with a
-            # CAPTCHA (``{"captchaId":"...","error":"We need to confirm..."}``).
-            # This is application-level, not Cloudflare-edge, and clears
-            # on its own within 1–4 hours of quiet traffic. There's no
-            # server-side solve — CAPTCHAs are intentionally unsolvable
-            # without a real browser. Surface the upstream message so the
-            # user can recognise it and reach for the "Open on MakerWorld"
-            # fallback instead of thinking the feature is broken.
-            upstream = _extract_upstream_error(response)
-            if upstream and "robot" in upstream.lower():
+            # Bambu's anti-abuse layer challenges the source IP with a CAPTCHA
+            # (``{"captchaId":"...","error":"We need to confirm..."}``). This is
+            # application-level, not Cloudflare-edge, and clears on its own
+            # within 1–4 hours of quiet traffic. There's no server-side solve —
+            # CAPTCHAs are intentionally unsolvable without a real browser.
+            # Surface the upstream message so the user can recognise it and
+            # reach for the "Open on MakerWorld" fallback instead of thinking
+            # the feature is broken.
+            #
+            # The same challenge also lands on the Bambu Cloud sign-in endpoint,
+            # so the shape test lives in ``bambu_cloud`` and is shared (#2790).
+            # It used to be a bare "robot" substring check on the error text,
+            # which missed a challenge worded any other way.
+            if is_captcha_challenge(response):
+                upstream = _extract_upstream_error(response)
+                detail = f" ({upstream})" if upstream else ""
                 raise MakerWorldUnavailableError(
-                    f"MakerWorld is challenging this IP with a CAPTCHA ({upstream}). "
+                    f"MakerWorld is challenging this IP with a CAPTCHA{detail}. "
                     "This usually clears within a few hours. In the meantime, use "
                     "'Open on MakerWorld' below to download the 3MF manually."
                 )

+ 146 - 6
backend/app/services/notification_service.py

@@ -123,10 +123,20 @@ class NotificationService:
         self._last_digest_check: str = ""  # "HH:MM" to avoid duplicate checks
 
     async def _get_client(self) -> httpx.AsyncClient:
-        """Get or create HTTP client."""
+        """Get or create HTTP client.
+
+        The connect timeout is deliberately far shorter than the rest. A flat
+        30 s meant that when a site's internet went down, every alarm spent a
+        full 30 s inside ``connect`` — longer than SQLite's 15 s
+        ``busy_timeout`` — and any other task that wanted to write during that
+        window failed with "database is locked" (#2770). Reaching a host either
+        works in a couple of seconds or is not going to; sending the body is the
+        part that legitimately takes time, so read/write keep the old 30 s and
+        an image upload on a slow uplink is unaffected.
+        """
         if self._http_client is None or self._http_client.is_closed:
             self._http_client = httpx.AsyncClient(
-                timeout=30.0,
+                timeout=httpx.Timeout(30.0, connect=5.0),
                 headers={"User-Agent": _USER_AGENT},
             )
         return self._http_client
@@ -166,12 +176,18 @@ class NotificationService:
             return False
 
     async def _get_template(self, db: AsyncSession, event_type: str) -> NotificationTemplate | None:
-        """Get a notification template by event type."""
+        """Get a notification template by event type.
+
+        ``no_autoflush`` for the same reason as ``_get_providers_for_event``:
+        this read runs before the provider is contacted, and must not be the
+        thing that opens a write transaction on the caller's session (#2770).
+        """
         # Check cache first
         if event_type in self._template_cache:
             return self._template_cache[event_type]
 
-        result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.event_type == event_type))
+        with db.no_autoflush:
+            result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.event_type == event_type))
         template = result.scalar_one_or_none()
 
         if template:
@@ -971,7 +987,19 @@ class NotificationService:
         event_field: str,
         printer_id: int | None = None,
     ) -> list[NotificationProvider]:
-        """Get all enabled providers that want a specific event type."""
+        """Get all enabled providers that want a specific event type.
+
+        Runs under ``no_autoflush`` (#2770). Callers routinely hold pending
+        writes when they raise an event — the AMS sensor loop does
+        ``db.add(history)`` and only commits after the alarms have gone out — and
+        without this, autoflush satisfies this SELECT by writing those rows,
+        which opens a write transaction on SQLite. The provider is then contacted
+        over the network with that transaction still open, so a site whose
+        internet is down holds the single SQLite writer for the whole connect
+        timeout and unrelated background tasks fail with "database is locked".
+        Deferring the flush costs nothing here: providers are committed rows, so
+        a pending change in the caller's session cannot be one this query wants.
+        """
         # Build the query dynamically based on event field
         query = select(NotificationProvider).where(
             NotificationProvider.enabled.is_(True),
@@ -983,7 +1011,8 @@ class NotificationService:
                 (NotificationProvider.printer_id.is_(None)) | (NotificationProvider.printer_id == printer_id)
             )
 
-        result = await db.execute(query)
+        with db.no_autoflush:
+            result = await db.execute(query)
         return list(result.scalars().all())
 
     async def _log_notification(
@@ -1353,6 +1382,39 @@ class NotificationService:
             variables=variables,
         )
 
+    async def on_billing_charge_failed(
+        self,
+        printer_id: int,
+        printer_name: str,
+        filename: str,
+        archive_id: int | None,
+        error: str,
+        db: AsyncSession,
+    ) -> None:
+        """Notify providers that a terminal print could not be charged."""
+        providers = await self._get_providers_for_event(db, "on_billing_charge_failed", printer_id)
+        if not providers:
+            return
+
+        variables = {
+            "printer": printer_name,
+            "filename": self._clean_filename(filename),
+            "archive_id": str(archive_id) if archive_id is not None else "Unknown",
+            "error": error,
+        }
+        title, message = await self._build_message_from_template(db, "billing_charge_failed", variables)
+        await self._send_to_providers(
+            providers,
+            title,
+            message,
+            db,
+            "billing_charge_failed",
+            printer_id,
+            printer_name,
+            force_immediate=True,
+            variables=variables,
+        )
+
     async def on_printer_offline(self, printer_id: int, printer_name: str, db: AsyncSession):
         """Handle printer offline event."""
         providers = await self._get_providers_for_event(db, "on_printer_offline", printer_id)
@@ -1631,6 +1693,47 @@ class NotificationService:
             variables=variables,
         )
 
+    async def on_ams_drying_suspended(
+        self,
+        printer_id: int,
+        printer_name: str,
+        ams_label: str,
+        humidity: float,
+        threshold: float,
+        cycles: int,
+        db: AsyncSession,
+    ):
+        """Handle automatic drying giving up on one AMS unit (#2770).
+
+        Sent immediately rather than folded into a digest: it reports that
+        Bambuddy has STOPPED doing something, and a report of inaction that
+        arrives with tomorrow's summary has already cost the user a day.
+        """
+        providers = await self._get_providers_for_event(db, "on_ams_drying_suspended", printer_id)
+        if not providers:
+            return
+
+        variables = {
+            "printer": printer_name,
+            "ams_label": ams_label,
+            "humidity": f"{humidity:.0f}",
+            "threshold": f"{threshold:.0f}",
+            "cycles": str(cycles),
+        }
+
+        title, message = await self._build_message_from_template(db, "ams_drying_suspended", variables)
+        await self._send_to_providers(
+            providers,
+            title,
+            message,
+            db,
+            "ams_drying_suspended",
+            printer_id,
+            printer_name,
+            force_immediate=True,
+            variables=variables,
+        )
+
     async def on_ams_ht_humidity_high(
         self,
         printer_id: int,
@@ -1729,6 +1832,43 @@ class NotificationService:
             providers, title, message, db, "bed_cooled", printer_id, printer_name, variables=variables
         )
 
+    async def on_ha_sensor_alert(
+        self,
+        printer_id: int,
+        printer_name: str,
+        sensor_name: str,
+        state: str,
+        db: AsyncSession,
+    ):
+        """A Home Assistant sensor bound to a printer entered its alert state (#1148).
+
+        Sent immediately rather than folded into a digest: the case this exists
+        for is an enclosure door left open, which is only worth telling someone
+        about while they can still act on it.
+        """
+        providers = await self._get_providers_for_event(db, "on_ha_sensor_alert", printer_id)
+        if not providers:
+            return
+
+        variables = {
+            "printer": printer_name,
+            "sensor": sensor_name,
+            "state": state,
+        }
+
+        title, message = await self._build_message_from_template(db, "ha_sensor_alert", variables)
+        await self._send_to_providers(
+            providers,
+            title,
+            message,
+            db,
+            "ha_sensor_alert",
+            printer_id,
+            printer_name,
+            force_immediate=True,
+            variables=variables,
+        )
+
     async def on_first_layer_complete(
         self,
         printer_id: int,

+ 8 - 30
backend/app/services/pipeline_eligibility.py

@@ -36,6 +36,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from backend.app.models.local_preset import LocalPreset
 from backend.app.models.printer import Printer
 from backend.app.models.slicer_pipeline import SlicerPipeline
+from backend.app.utils.filament_types import canonical_filament_type
 
 IssueKind = Literal[
     "printer_not_set",
@@ -80,36 +81,13 @@ class EligibilityReport:
     printer_reports: tuple[PerPrinterReport, ...] = ()
 
 
-# Same equivalence map as print_scheduler._canonical_filament_type but kept
-# local so this module has no upward dependency on the scheduler. Mirrors the
-# scheduler's behaviour: BBL-prefixed product names normalise to the base type
-# (e.g. "PLA Basic" → "PLA"). When the scheduler's map gets a new alias, this
-# one needs the same entry.
-_FILAMENT_EQUIV_MAP = {
-    "PLA": "PLA",
-    "PLA BASIC": "PLA",
-    "PLA MATTE": "PLA",
-    "PLA SILK": "PLA",
-    "PLA PRO": "PLA",
-    "PLA TOUGH": "PLA",
-    "PETG": "PETG",
-    "PETG HF": "PETG",
-    "PETG BASIC": "PETG",
-    "PETG TRANSLUCENT": "PETG",
-    "ABS": "ABS",
-    "ASA": "ASA",
-    "TPU": "TPU",
-    "TPU 95A": "TPU",
-    "PC": "PC",
-    "PA": "PA",
-    "PA-CF": "PA",
-    "PVA": "PVA",
-}
-
-
-def _canonical(ftype: str) -> str:
-    upper = (ftype or "").strip().upper()
-    return _FILAMENT_EQUIV_MAP.get(upper, upper)
+# This module's whole job is to predict what the dispatch matcher will do, so
+# it reads type equivalence from the same table the matcher does rather than
+# keeping a copy. The copy it used to keep had drifted into disagreeing in both
+# directions — it aliased "PLA Basic" to "PLA" where the matcher does not, so a
+# job could pass here and then fail on type; and it lacked the PA12-CF/PAHT-CF
+# grouping the matcher has, so a job the matcher handles fine was flagged.
+_canonical = canonical_filament_type
 
 
 def _normalise_colour(colour: str | None) -> str:

+ 543 - 0
backend/app/services/print_batch.py

@@ -0,0 +1,543 @@
+"""Batch order planning: per-plate targets, progress, and staged dispatch (#342).
+
+A batch stores *intent* in :class:`PrintBatchPlate` rows — "this order wants 3
+of plate 2" — while its queue items record what was actually dispatched.
+Everything here derives one from the other.
+
+The distinction matters for exactly one reason, and it is the reason the
+feature exists: a failed or cancelled run does not count towards the target, so
+``remaining`` goes back up and the order still says it owes a print. A design
+that only counted the items it created could not tell "the user cancelled this
+deliberately" apart from "this one burned and needs reprinting".
+
+Batches created before targets existed have no plate rows. They still report
+progress — the plate breakdown is derived from their queue items and every
+target simply equals the number of items dispatched, so ``remaining`` is zero
+and the dispatch endpoint has nothing to do. ``has_targets`` tells callers
+which kind of batch they are looking at.
+"""
+
+import logging
+from dataclasses import dataclass, field
+from datetime import datetime, timezone
+
+from sqlalchemy import func, select, text
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from backend.app.models.print_batch import PrintBatch, PrintBatchPlate
+from backend.app.models.print_log import PrintLogEntry
+from backend.app.models.print_queue import PrintQueueItem, PrintQueueVariant
+
+logger = logging.getLogger(__name__)
+
+# Statuses that consume a unit of the target. "printing" counts because the
+# run is in flight — re-dispatching it would double-print. "failed",
+# "cancelled" and "skipped" deliberately do not.
+CONSUMING_STATUSES = ("pending", "printing", "completed")
+
+# Queue statuses the roll-up has a counter for. Anything else is ignored rather
+# than crashing the page — the queue's status vocabulary is allowed to grow
+# without this module having to be updated in lockstep.
+COUNTED_STATUSES = ("pending", "printing", "completed", "failed", "cancelled", "skipped")
+
+# Columns copied onto a clone when dispatching more of a plate. This is the
+# print *configuration* the user already chose and the API already validated —
+# copying the row is what keeps a second dispatch identical to the first
+# without re-serialising twenty fields through a template blob that would drift
+# from the model the first time someone adds a column.
+CLONED_SETTING_COLUMNS = (
+    "printer_id",
+    "target_model",
+    "target_location",
+    "required_filament_types",
+    "archive_id",
+    "library_file_id",
+    "project_id",
+    "batch_id",
+    "ams_mapping",
+    "filament_overrides",
+    "plate_id",
+    "print_time_seconds",
+    "gcode_injection",
+    "nozzle_mapping",
+    "nozzle_rack_choice",
+    "require_previous_success",
+    "auto_off_after",
+    "manual_start",
+    "bed_levelling",
+    "flow_cali",
+    "vibration_cali",
+    "layer_inspect",
+    "timelapse",
+    "use_ams",
+    "nozzle_offset_cali",
+    "preheat_override",
+    "preheat_chamber_target_override",
+    "skip_filament_check",
+)
+
+CLONED_VARIANT_COLUMNS = (
+    "position",
+    "library_file_id",
+    "target_model",
+    "plate_id",
+    "ams_mapping",
+    "nozzle_mapping",
+    "nozzle_rack_choice",
+    "filament_overrides",
+    "required_filament_types",
+    "print_time_seconds",
+)
+
+
+class BatchDispatchError(Exception):
+    """Raised when more runs are owed but nothing can be cloned to produce them."""
+
+
+@dataclass
+class PlateProgress:
+    """Per-plate roll-up for one batch."""
+
+    plate_id: int | None
+    plate_name: str | None
+    quantity_target: int
+    sort_order: int = 0
+    pending: int = 0
+    printing: int = 0
+    completed: int = 0
+    failed: int = 0
+    cancelled: int = 0
+    skipped: int = 0
+    # Actual material + energy cost of this plate's finished runs. None when no
+    # run has produced a cost yet — reported as "unknown", never as zero.
+    actual_cost: float | None = None
+    filament_used_grams: float | None = None
+    print_time_seconds: int = 0
+
+    @property
+    def dispatched(self) -> int:
+        return self.pending + self.printing + self.completed
+
+    @property
+    def remaining(self) -> int:
+        return max(0, self.quantity_target - self.dispatched)
+
+    @property
+    def cost_per_run(self) -> float | None:
+        """Observed mean cost of this plate's completed runs, or None.
+
+        Deliberately measured rather than estimated from the file: the file's
+        estimate ignores what the run actually consumed, and a plate that has
+        never completed has no honest number to show.
+        """
+        if self.completed <= 0 or self.actual_cost is None:
+            return None
+        return self.actual_cost / self.completed
+
+    @property
+    def estimated_remaining_cost(self) -> float | None:
+        per_run = self.cost_per_run
+        if per_run is None:
+            return None
+        return per_run * self.remaining
+
+
+@dataclass
+class BatchProgress:
+    """Whole-order roll-up, plus the per-plate breakdown it was derived from."""
+
+    plates: list[PlateProgress] = field(default_factory=list)
+    has_targets: bool = False
+
+    def _sum(self, attr: str) -> int:
+        return sum(getattr(p, attr) for p in self.plates)
+
+    @property
+    def pending(self) -> int:
+        return self._sum("pending")
+
+    @property
+    def printing(self) -> int:
+        return self._sum("printing")
+
+    @property
+    def completed(self) -> int:
+        return self._sum("completed")
+
+    @property
+    def failed(self) -> int:
+        return self._sum("failed")
+
+    @property
+    def cancelled(self) -> int:
+        return self._sum("cancelled")
+
+    @property
+    def skipped(self) -> int:
+        return self._sum("skipped")
+
+    @property
+    def target(self) -> int:
+        return self._sum("quantity_target")
+
+    @property
+    def remaining(self) -> int:
+        return self._sum("remaining")
+
+    @property
+    def actual_cost(self) -> float | None:
+        costs = [p.actual_cost for p in self.plates if p.actual_cost is not None]
+        return sum(costs) if costs else None
+
+    @property
+    def estimated_remaining_cost(self) -> float | None:
+        estimates = [p.estimated_remaining_cost for p in self.plates if p.estimated_remaining_cost is not None]
+        return sum(estimates) if estimates else None
+
+    @property
+    def filament_used_grams(self) -> float | None:
+        grams = [p.filament_used_grams for p in self.plates if p.filament_used_grams is not None]
+        return sum(grams) if grams else None
+
+    @property
+    def print_time_seconds(self) -> int:
+        return self._sum("print_time_seconds")
+
+    @property
+    def is_fulfilled(self) -> bool:
+        """True when every target is met and nothing is still in flight.
+
+        A zero total target is never "fulfilled". Without that guard a legacy
+        batch whose items were all cancelled one by one would report itself
+        completed — its derived target counts only pending/printing/completed
+        items, so cancelling the lot leaves a target of zero that trivially
+        satisfies ``remaining == 0``.
+        """
+        return self.target > 0 and self.remaining == 0 and self.pending == 0 and self.printing == 0
+
+
+async def load_progress(db: AsyncSession, batch: PrintBatch) -> BatchProgress:
+    """Build the per-plate progress roll-up for *batch*.
+
+    Two queries plus one for costs, regardless of how many plates the order
+    has — this runs once per batch in the list endpoint.
+    """
+    plate_rows = (await db.execute(select(PrintBatchPlate).where(PrintBatchPlate.batch_id == batch.id))).scalars().all()
+
+    # (plate_id, status) -> count, plus the time/weight actually recorded.
+    item_rows = (
+        await db.execute(
+            select(
+                PrintQueueItem.plate_id,
+                PrintQueueItem.status,
+                func.count(PrintQueueItem.id),
+                func.sum(PrintQueueItem.print_time_seconds),
+            )
+            .where(PrintQueueItem.batch_id == batch.id)
+            .group_by(PrintQueueItem.plate_id, PrintQueueItem.status)
+        )
+    ).all()
+
+    # Per-run actuals, attributed through the queue item that produced them.
+    # PrintLogEntry is the authoritative per-run record (#1378) and is already
+    # scoped to the printed plate (#2614), so a multi-plate order gets each
+    # plate's own cost rather than the whole file's.
+    cost_rows = (
+        await db.execute(
+            select(
+                PrintQueueItem.plate_id,
+                func.sum(func.coalesce(PrintLogEntry.cost, 0.0) + func.coalesce(PrintLogEntry.energy_cost, 0.0)),
+                func.sum(PrintLogEntry.filament_used_grams),
+            )
+            .select_from(PrintLogEntry)
+            .join(PrintQueueItem, PrintLogEntry.queue_item_id == PrintQueueItem.id)
+            .where(PrintQueueItem.batch_id == batch.id)
+            .group_by(PrintQueueItem.plate_id)
+        )
+    ).all()
+    costs = {row[0]: (row[1], row[2]) for row in cost_rows}
+
+    progress = BatchProgress(has_targets=bool(plate_rows))
+    by_plate: dict[int | None, PlateProgress] = {}
+
+    for row in plate_rows:
+        by_plate[row.plate_id] = PlateProgress(
+            plate_id=row.plate_id,
+            plate_name=row.plate_name,
+            quantity_target=row.quantity_target,
+            sort_order=row.sort_order,
+        )
+
+    for plate_id, status, count, time_sum in item_rows:
+        plate = by_plate.get(plate_id)
+        if plate is None:
+            # A queue item for a plate the order has no target row for: either
+            # a legacy batch, or an item grouped in by hand after the fact.
+            # Its own dispatched count becomes its target so it reads as
+            # complete rather than as owing work nobody asked for.
+            plate = PlateProgress(plate_id=plate_id, plate_name=None, quantity_target=0, sort_order=plate_id or 0)
+            by_plate[plate_id] = plate
+            if status in CONSUMING_STATUSES:
+                plate.quantity_target += count
+        elif not progress.has_targets and status in CONSUMING_STATUSES:
+            plate.quantity_target += count
+        if status in COUNTED_STATUSES:
+            setattr(plate, status, getattr(plate, status) + count)
+        else:
+            logger.debug("Batch %s: ignoring queue item status %r in progress roll-up", batch.id, status)
+        plate.print_time_seconds += int(time_sum or 0)
+
+    for plate_id, (cost_sum, gram_sum) in costs.items():
+        plate = by_plate.get(plate_id)
+        if plate is None:
+            continue
+        plate.actual_cost = float(cost_sum) if cost_sum else None
+        plate.filament_used_grams = float(gram_sum) if gram_sum else None
+
+    progress.plates = sorted(by_plate.values(), key=lambda p: (p.sort_order, p.plate_id or 0))
+    return progress
+
+
+async def refresh_batch_status(db: AsyncSession, batch: PrintBatch) -> bool:
+    """Flip an ``active`` batch to ``completed`` once its targets are met.
+
+    Returns True when the status changed. A ``cancelled`` batch is never
+    resurrected, and a ``completed`` batch drops back to ``active`` if its
+    targets grow — raising a target on a finished order reopens it rather than
+    leaving a "completed" order that still owes prints.
+    """
+    progress = await load_progress(db, batch)
+
+    if batch.status == "cancelled":
+        return False
+
+    if batch.status == "active" and progress.is_fulfilled:
+        batch.status = "completed"
+        batch.completed_at = datetime.now(timezone.utc)
+        logger.info("Batch %s fulfilled — marked completed", batch.id)
+        return True
+
+    # A grouping whose every item was cancelled one at a time is finished, but
+    # nothing was produced, so "completed" would be a lie and `is_fulfilled`
+    # rightly refuses it (its derived target is zero). Left alone it would sit
+    # on "active" forever. Cancelled is what it is, and matches what the
+    # batch-level Cancel action would have set had it been used.
+    #
+    # Deliberately not applied to orders: an order states its intent
+    # independently of its runs, so cancelling every run still leaves it owing
+    # work and offering to re-queue it. A grouping has no such statement — it
+    # was only ever the sum of its items.
+    if batch.status == "active" and not progress.has_targets and progress.completed == 0:
+        settled = progress.pending == 0 and progress.printing == 0
+        if settled and progress.cancelled > 0 and progress.failed == 0 and progress.skipped == 0:
+            batch.status = "cancelled"
+            logger.info("Batch %s had every item cancelled — marked cancelled", batch.id)
+            return True
+
+    if batch.status == "completed" and not progress.is_fulfilled:
+        batch.status = "active"
+        batch.completed_at = None
+        logger.info("Batch %s reopened — targets no longer met", batch.id)
+        return True
+
+    return False
+
+
+async def backfill_batch_statuses(db: AsyncSession) -> int:
+    """Close out ``active`` batches that finished before the status existed.
+
+    ``completed`` only became reachable with #342. Every batch created since
+    the feature shipped in April 2026 is therefore still marked ``active``,
+    however long ago its last run finished — so without this pass the Batches
+    tab opens on months of accumulated history.
+
+    Runs on every startup rather than once behind a marker: it is cheap (only
+    batches with nothing in flight are even considered), it is idempotent, and
+    repeating it also closes out any order whose last run landed while the
+    process was down.
+
+    Returns the number of batches whose status changed.
+    """
+    candidates = (
+        (
+            await db.execute(
+                select(PrintBatch)
+                .where(PrintBatch.status == "active")
+                # Anything still queued or printing is by definition unfinished,
+                # and re-deriving its progress would change nothing.
+                .where(
+                    ~select(PrintQueueItem.id)
+                    .where(PrintQueueItem.batch_id == PrintBatch.id)
+                    .where(PrintQueueItem.status.in_(("pending", "printing")))
+                    .exists()
+                )
+            )
+        )
+        .scalars()
+        .all()
+    )
+
+    changed = 0
+    for batch in candidates:
+        if await refresh_batch_status(db, batch):
+            changed += 1
+
+    if changed:
+        await db.commit()
+        logger.info("Marked %d finished batch(es) as completed at startup (#342)", changed)
+    return changed
+
+
+async def refresh_batch_status_for_item(db: AsyncSession, queue_item_id: int) -> None:
+    """Re-evaluate the batch owning *queue_item_id*, if it has one.
+
+    Called from the print-completion path so a finished order reports itself
+    complete the moment its last run lands, rather than whenever someone next
+    opens the page.
+    """
+    batch_id = (
+        await db.execute(select(PrintQueueItem.batch_id).where(PrintQueueItem.id == queue_item_id))
+    ).scalar_one_or_none()
+    if batch_id is None:
+        return
+    batch = (await db.execute(select(PrintBatch).where(PrintBatch.id == batch_id))).scalar_one_or_none()
+    if batch is None:
+        return
+    await refresh_batch_status(db, batch)
+
+
+async def _next_position(db: AsyncSession, printer_id: int | None) -> int:
+    """Next free queue position in the scope a clone will land in.
+
+    Positions are per-queue, not global: one sequence per printer plus one
+    shared sequence for unassigned / model-based items, matching the scope the
+    add-to-queue route uses. Taking a global MAX here would drop every clone
+    at the end of whichever printer's queue happens to be longest and scramble
+    the order the user sees.
+    """
+    # Same advisory lock the add-to-queue route takes (#1625-followup): two
+    # concurrent inserts into an empty scope would otherwise both read
+    # MAX(position) as 0 and land on position 1. SQLite serialises writes
+    # implicitly and needs no equivalent.
+    bind = db.get_bind()
+    if bind.dialect.name == "postgresql":
+        await db.execute(
+            text("SELECT pg_advisory_xact_lock(1625, :k)"), {"k": printer_id if printer_id is not None else 0}
+        )
+
+    scope = PrintQueueItem.printer_id == printer_id if printer_id is not None else PrintQueueItem.printer_id.is_(None)
+    max_pos = (
+        await db.execute(
+            select(func.max(PrintQueueItem.position)).where(scope).where(PrintQueueItem.status == "pending")
+        )
+    ).scalar() or 0
+    return max_pos + 1
+
+
+def _clone_queue_item(source: PrintQueueItem, *, position: int, created_by_id: int | None) -> PrintQueueItem:
+    """Copy *source*'s print configuration into a fresh pending item.
+
+    Lifecycle state (status, timestamps, retry counters, scheduler flags) is
+    deliberately not copied — the clone is a new run, not a resurrection.
+
+    ``scheduled_time`` is dropped too: dispatching more of a plate is a
+    "queue this now" action, and replaying the original's scheduled time would
+    either fire immediately (it is in the past) or silently park the new run
+    until a moment the user chose for a different print.
+
+    ``cleanup_library_after_dispatch`` is forced off. It only ever comes from
+    the Printers-page direct-print flow, where it deletes the transient library
+    row after dispatch — replaying that on a clone would delete the source file
+    out from under the rest of the order.
+    """
+    clone = PrintQueueItem(
+        status="pending",
+        position=position,
+        created_by_id=created_by_id if created_by_id is not None else source.created_by_id,
+        cleanup_library_after_dispatch=False,
+    )
+    for column in CLONED_SETTING_COLUMNS:
+        setattr(clone, column, getattr(source, column))
+    return clone
+
+
+async def dispatch_remaining(
+    db: AsyncSession,
+    batch: PrintBatch,
+    *,
+    plate_id: int | None = None,
+    only_plate: bool = False,
+    limit: int | None = None,
+    created_by_id: int | None = None,
+) -> list[PrintQueueItem]:
+    """Create queue items for the runs *batch* still owes.
+
+    ``only_plate`` restricts the dispatch to the single plate named by
+    ``plate_id`` (which may legitimately be ``None`` for a single-plate file);
+    otherwise every plate with work outstanding is dispatched in plate order.
+    ``limit`` caps the total number of items created across all plates.
+
+    Raises :class:`BatchDispatchError` when a plate owes runs but has no
+    existing item to clone — the order can describe work it has never once
+    dispatched, and there is no configuration to copy in that case.
+    """
+    progress = await load_progress(db, batch)
+    if not progress.has_targets:
+        return []
+
+    targets = [p for p in progress.plates if p.remaining > 0]
+    if only_plate:
+        targets = [p for p in targets if p.plate_id == plate_id]
+
+    created: list[PrintQueueItem] = []
+
+    for plate in targets:
+        if limit is not None and len(created) >= limit:
+            break
+
+        source = (
+            await db.execute(
+                select(PrintQueueItem)
+                .options(selectinload(PrintQueueItem.variants))
+                .where(PrintQueueItem.batch_id == batch.id)
+                .where(PrintQueueItem.plate_id == plate.plate_id)
+                .order_by(PrintQueueItem.id.desc())
+                .limit(1)
+            )
+        ).scalar_one_or_none()
+
+        if source is None:
+            raise BatchDispatchError(
+                f"Plate {plate.plate_id if plate.plate_id is not None else 1} has no queued or finished run to "
+                "copy settings from. Queue it once from the file, then dispatch the rest from here."
+            )
+
+        wanted = plate.remaining
+        if limit is not None:
+            wanted = min(wanted, limit - len(created))
+
+        # One scope per source printer; clones for this plate all land in it,
+        # appended after whatever is already queued there.
+        position = await _next_position(db, source.printer_id)
+
+        for _ in range(wanted):
+            clone = _clone_queue_item(source, position=position, created_by_id=created_by_id)
+            position += 1
+            db.add(clone)
+            await db.flush()
+            for variant in source.variants:
+                cloned_variant = PrintQueueVariant(queue_item_id=clone.id)
+                for column in CLONED_VARIANT_COLUMNS:
+                    setattr(cloned_variant, column, getattr(variant, column))
+                db.add(cloned_variant)
+            created.append(clone)
+
+    if created:
+        # Dispatching more work can only ever un-fulfil an order, but run the
+        # check anyway so a reopened batch flips back from completed.
+        await db.flush()
+        await refresh_batch_status(db, batch)
+
+    logger.info("Dispatched %d item(s) for batch %s", len(created), batch.id)
+    return created

+ 165 - 0
backend/app/services/print_cost_estimate.py

@@ -0,0 +1,165 @@
+"""Trusted server-side cost estimates for queued prints."""
+
+import json
+import logging
+from pathlib import Path
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from backend.app.core.config import settings
+from backend.app.models.archive import PrintArchive
+from backend.app.models.library import LibraryFile
+from backend.app.models.spool_assignment import SpoolAssignment
+from backend.app.utils import threemf_tools
+from backend.app.utils.safe_path import safe_join_under
+
+logger = logging.getLogger(__name__)
+
+
+def plate_scoped_run_estimate(
+    archive: PrintArchive,
+    full_path: Path | None,
+    plate_id: int | None = None,
+) -> tuple[float | None, float | None]:
+    """Return trusted ``(grams, cost)`` for one run of an archive plate."""
+
+    whole_grams = archive.filament_used_grams
+    selected_plate = archive.plate_id if plate_id is None else plate_id
+    if selected_plate is None or full_path is None or not full_path.exists():
+        return whole_grams, archive.cost
+    try:
+        plate_grams = threemf_tools.extract_plate_metadata_from_3mf(full_path, selected_plate).filament_used_grams
+    except Exception as exc:
+        logger.debug(
+            "Plate-scoped estimate failed for archive %s (plate %s): %s",
+            archive.id,
+            selected_plate,
+            exc,
+        )
+        return whole_grams, archive.cost
+    if not plate_grams or plate_grams <= 0:
+        return whole_grams, archive.cost
+    plate_cost = archive.cost
+    if archive.cost and whole_grams and whole_grams > 0:
+        plate_cost = round(archive.cost * (plate_grams / whole_grams), 2)
+    return round(plate_grams, 2), plate_cost
+
+
+def _source_path(library_file: LibraryFile) -> Path:
+    path = Path(library_file.file_path)
+    if path.is_absolute():
+        # SEC-PATH-OK: absolute paths are persisted LibraryFile locations for
+        # configured external libraries; this branch performs no path join.
+        return path
+    return safe_join_under(settings.base_dir, library_file.file_path, http=False)
+
+
+def _parse_mapping(mapping: list[int] | str | None) -> list[int] | None:
+    if isinstance(mapping, list):
+        return mapping
+    if isinstance(mapping, str):
+        try:
+            parsed = json.loads(mapping)
+        except (TypeError, json.JSONDecodeError):
+            return None
+        return parsed if isinstance(parsed, list) else None
+    return None
+
+
+def _global_tray_id(assignment: SpoolAssignment) -> int:
+    if assignment.ams_id == 255:
+        return 254 + assignment.tray_id
+    if assignment.ams_id >= 128:
+        return assignment.ams_id
+    return assignment.ams_id * 4 + assignment.tray_id
+
+
+async def _default_cost_per_kg(db: AsyncSession) -> float:
+    from backend.app.api.routes.settings import get_setting
+
+    raw = await get_setting(db, "default_filament_cost")
+    try:
+        return float(raw) if raw is not None else 25.0
+    except (TypeError, ValueError):
+        return 25.0
+
+
+async def estimate_queue_source_cost(
+    db: AsyncSession,
+    *,
+    archive: PrintArchive | None = None,
+    library_file: LibraryFile | None = None,
+    plate_id: int | None = None,
+    ams_mapping: list[int] | str | None = None,
+    printer_id: int | None = None,
+) -> float | None:
+    """Compute a queue cost without trusting the request's display hint."""
+
+    if archive is not None:
+        archive_path = settings.base_dir / archive.file_path
+        grams, cost = plate_scoped_run_estimate(archive, archive_path, plate_id)
+        if cost is not None and cost > 0:
+            return float(cost)
+        # Older archives and imports can have trustworthy filament usage but
+        # no stored cost. Model-based and multi-printer jobs have no single
+        # spool mapping at enqueue time, so use the server setting rather than
+        # requiring the browser to provide an estimate.
+        if grams is None or grams <= 0:
+            return None
+        default_cost = await _default_cost_per_kg(db)
+        estimated_cost = (grams / 1000.0) * default_cost
+        return max(0.01, round(estimated_cost, 2)) if estimated_cost > 0 else None
+
+    if library_file is None:
+        return None
+
+    path = _source_path(library_file)
+    usage: list[dict] = []
+    if path.exists():
+        usage = threemf_tools.extract_plate_metadata_from_3mf(path, plate_id).filament_usage
+
+    metadata = library_file.file_metadata or {}
+    if not usage:
+        try:
+            grams = float(metadata.get("filament_used_grams") or 0)
+        except (TypeError, ValueError):
+            grams = 0
+        if grams > 0:
+            usage = [{"slot_id": 1, "used_g": grams}]
+
+    if not usage:
+        return None
+
+    default_cost = await _default_cost_per_kg(db)
+    cost_by_tray: dict[int, float | None] = {}
+    mapping = _parse_mapping(ams_mapping)
+    if printer_id is not None and mapping:
+        assignments = (
+            (
+                await db.execute(
+                    select(SpoolAssignment)
+                    .options(selectinload(SpoolAssignment.spool))
+                    .where(SpoolAssignment.printer_id == printer_id)
+                )
+            )
+            .scalars()
+            .all()
+        )
+        cost_by_tray = {_global_tray_id(a): a.spool.cost_per_kg for a in assignments}
+
+    total = 0.0
+    for filament in usage:
+        try:
+            slot_id = int(filament.get("slot_id") or 0)
+            grams = float(filament.get("used_g") or 0)
+        except (TypeError, ValueError):
+            continue
+        tray_id = mapping[slot_id - 1] if mapping and 0 < slot_id <= len(mapping) else None
+        cost_per_kg = cost_by_tray.get(tray_id) if tray_id is not None else None
+        if cost_per_kg is None or cost_per_kg <= 0:
+            cost_per_kg = default_cost
+        total += (grams / 1000.0) * cost_per_kg
+
+    return round(total, 2) if total > 0 else None

+ 2 - 0
backend/app/services/print_log.py

@@ -18,6 +18,7 @@ async def write_log_entry(
     *,
     status: str,
     archive_id: int | None = None,
+    queue_item_id: int | None = None,
     print_name: str | None = None,
     printer_name: str | None = None,
     printer_id: int | None = None,
@@ -56,6 +57,7 @@ async def write_log_entry(
 
     entry = PrintLogEntry(
         archive_id=archive_id,
+        queue_item_id=queue_item_id,
         print_name=print_name,
         printer_name=printer_name,
         printer_id=printer_id,

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 736 - 86
backend/app/services/print_scheduler.py


+ 147 - 0
backend/app/services/print_storage.py

@@ -0,0 +1,147 @@
+"""Can FTPS see the file this print is running from? (#2780)
+
+Bambuddy reads a print's 3MF, cover and timelapse off the printer over implicit
+FTPS on port 990. On every Bambu model that port serves **external storage only**
+-- the SD card or USB stick. It is not a view of the printer's filesystem.
+
+H2-series and P2S firmware default to keeping the sliced file on internal eMMC
+instead, and BambuStudio uploads there over a separate service on port 6000
+(the "BambuTunnelLocal" protocol -- see #2762, which tracks implementing it).
+When that happens there is no file on FTPS to find, at any path, and no TLS
+option, retry or directory guess changes that. The dispatch says so plainly:
+the ``project_file`` command carries ``url``, which is ``ftp://<name>`` for
+external storage and ``brtc://emmc/<name>`` for internal.
+
+Before this module we ignored ``url`` and swept anyway: six filename variants
+across five directories with up to four retries for the 3MF, then sixteen more
+paths for the cover, then the timelapse scan -- roughly 110 FTPS connections per
+print, every one of them certain to 550. The user-visible result was an archive
+card with nothing on it and no stated reason, which read as a Bambuddy bug and
+was reported as one four times (#1170, #2524, #2762, #2780).
+
+The rule here is deliberately one-sided: **skip only on positive evidence**.
+Silence is not evidence -- a printer that never publishes ``sdcard`` and never
+had a ``project_file`` pass through the request topic (some brokers refuse the
+subscription) must keep the old behaviour exactly, or this becomes a regression
+for installs whose archives work fine today.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+# The one URL scheme that means "on external storage, reachable over FTPS".
+# Anything else -- brtc://emmc today, whatever Bambu ships next -- is somewhere
+# port 990 does not serve. Matching the reachable value rather than the
+# unreachable one is what keeps a new scheme from silently reading as fine.
+_EXTERNAL_STORAGE_SCHEME = "ftp"
+
+# Reason slugs. These cross the API into the UI and into the connection
+# diagnostic, so they are part of the contract: the frontend maps each to its
+# own explanation and its own advice. Keep them stable.
+REASON_INTERNAL_STORAGE = "internal_storage"
+REASON_NO_EXTERNAL_STORAGE = "no_external_storage"
+
+
+@dataclass(frozen=True)
+class StorageVerdict:
+    """Whether an FTPS sweep for this print's file is worth running.
+
+    ``reachable`` False always carries a ``reason``; True never does.
+    """
+
+    reachable: bool
+    reason: str | None = None
+
+
+_REACHABLE = StorageVerdict(reachable=True)
+
+
+def url_is_external_storage(project_url: str | None) -> bool | None:
+    """Does *project_url* name a file on external storage?
+
+    ``None`` when there is no URL to read, which is not the same answer as
+    False and must not be collapsed into one by callers.
+    """
+    # Type-checked, not just truth-checked: this value arrives straight off the
+    # wire, so it is whatever the sender put there. Anything that is not a
+    # string is not an answer.
+    if not isinstance(project_url, str) or not project_url:
+        return None
+    scheme, separator, _ = project_url.partition("://")
+    if not separator:
+        # No scheme at all. Real dispatches always carry one, so rather than
+        # guess at a bare path, decline to answer and let the caller fall
+        # through to its existing behaviour.
+        return None
+    return scheme.lower() == _EXTERNAL_STORAGE_SCHEME
+
+
+def external_storage_present(state: object | None) -> bool:
+    """Does the printer have external storage for FTPS to serve at all?
+
+    Narrower than :func:`print_file_reachable_over_ftp` and deliberately so.
+    The printer records its timelapse to the card itself, so *where the sliced
+    file went* says nothing about whether a video exists -- an H2C that kept
+    the 3MF on eMMC still writes ``/timelapse`` to an inserted card. Only the
+    empty-slot case rules a scan out, and only when the printer said the slot
+    is empty rather than never mentioning it.
+    """
+    if state is None:
+        return True
+    return not (getattr(state, "sdcard_reported", False) and not getattr(state, "sdcard", False))
+
+
+def print_file_reachable_over_ftp(state: object | None) -> StorageVerdict:
+    """Decide whether to run an FTPS sweep for the print *state* is running.
+
+    *state* is a ``PrinterState`` (duck-typed so tests and callers can pass a
+    stand-in). Reads ``current_project_url``, ``sdcard`` and ``sdcard_reported``.
+
+    Deliberately the *per-print* URL, not the sticky one: a print Bambuddy saw
+    no dispatch for must read as unknown and sweep, rather than inherit the
+    previous job's destination. Roughly a fifth of the print starts in #2780's
+    bundle had no dispatch on the request topic -- touchscreen reprints and
+    restart recovery -- and inheriting a stale internal-storage answer there
+    would skip a sweep that could have found the file.
+
+    Returns :data:`_REACHABLE` unless something positively says otherwise.
+    """
+    return _verdict(getattr(state, "current_project_url", None), state)
+
+
+def last_print_storage_verdict(state: object | None) -> StorageVerdict:
+    """Same question, asked of the last dispatch seen whenever that was.
+
+    For reporting only -- the connection diagnostic is normally run after the
+    print that prompted it, by which point the per-print URL has been cleared.
+    Never gate an FTPS sweep on this: it may describe a different print.
+    """
+    return _verdict(getattr(state, "last_project_url", None), state)
+
+
+def _verdict(project_url: str | None, state: object | None) -> StorageVerdict:
+    if state is None:
+        return _REACHABLE
+
+    # Strongest signal, and specific to the print in question: the dispatcher
+    # named the destination.
+    external = url_is_external_storage(project_url)
+    if external is False:
+        return StorageVerdict(reachable=False, reason=REASON_INTERNAL_STORAGE)
+    if external is True:
+        # It said external storage, so sweep even if the card flags disagree.
+        # Trusting the specific claim over the general one is what keeps a
+        # printer that misreports `sdcard` from losing archives that work
+        # today -- a false skip is a regression, a needless sweep is only slow.
+        return _REACHABLE
+
+    # Model-independent fallback for printers whose broker refuses the request
+    # topic, so we never see a `project_file` at all. An empty slot means FTPS
+    # has nothing to serve from any path -- but only when the printer actually
+    # said so. `sdcard` defaults to False, and acting on that default would
+    # skip the sweep for every printer that simply doesn't publish the field.
+    if getattr(state, "sdcard_reported", False) and not getattr(state, "sdcard", False):
+        return StorageVerdict(reachable=False, reason=REASON_NO_EXTERNAL_STORAGE)
+
+    return _REACHABLE

+ 96 - 5
backend/app/services/printer_diagnostic.py

@@ -13,12 +13,15 @@ import asyncio
 import ipaddress
 import logging
 import socket
+import ssl
 
 from backend.app.models.printer import Printer
 from backend.app.schemas.printer import DiagnosticCheck, PrinterDiagnosticResult
 from backend.app.services.bambu_mqtt import CONNECT_ERROR_AUTH_REJECTED
 from backend.app.services.camera import get_camera_port
 from backend.app.services.discovery import is_running_in_docker
+from backend.app.services.ftp_profiles import get_ftp_profile
+from backend.app.services.print_storage import REASON_INTERNAL_STORAGE, last_print_storage_verdict
 from backend.app.services.printer_manager import printer_manager
 from backend.app.utils.printer_models import has_external_storage, has_remote_storage_toggle
 
@@ -63,6 +66,56 @@ async def _check_port(ip: str, port: int, timeout: float = _PORT_PROBE_TIMEOUT)
 check_port = _check_port
 
 
+async def _check_ftps_tls(ip: str, model: str | None, timeout: float = _PORT_PROBE_TIMEOUT) -> str:
+    """Probe port 990 the way the FTP client does, and say how far it got.
+
+    Returns ``"ok"``, ``"closed"`` (nothing accepted the TCP connection) or
+    ``"no_tls"`` (the port accepted the connection but the TLS handshake did
+    not complete).
+
+    A plain TCP probe cannot tell the last two apart, which is exactly how
+    #2780 hid: the reporter's diagnostic reported port 990 as reachable and
+    green while every real transfer died in the handshake with
+    ``WRONG_VERSION_NUMBER``, so archives quietly arrived empty with nothing
+    on screen to explain it.
+
+    The context mirrors :class:`~backend.app.services.bambu_ftp.ImplicitFTP_TLS`
+    -- including the model's TLS cap -- so a pass here means the FTP client
+    would also get through. Handshake only; no login is attempted, so this
+    stays valid for the pre-save Add-Printer flow where no access code exists
+    yet.
+    """
+    context = ssl.create_default_context()
+    context.check_hostname = False
+    context.verify_mode = ssl.CERT_NONE
+    context.minimum_version = ssl.TLSVersion.TLSv1_2
+    if get_ftp_profile(model).cap_tls_v1_2:
+        context.maximum_version = ssl.TLSVersion.TLSv1_2
+
+    writer = None
+    try:
+        _reader, writer = await asyncio.wait_for(
+            asyncio.open_connection(ip, PORT_FTPS, ssl=context),
+            timeout=timeout,
+        )
+        return "ok"
+    except ssl.SSLError:
+        # The socket was accepted and then failed to negotiate TLS. Reaching
+        # here at all proves something is listening, so this is never "port
+        # blocked" -- it is the printer's file service in a state no retry
+        # gets past.
+        return "no_tls"
+    except Exception:
+        return "closed"
+    finally:
+        if writer is not None:
+            writer.close()
+            try:
+                await writer.wait_closed()
+            except Exception:
+                pass
+
+
 def _auth_reason_params(reason: str | None) -> dict:
     """Map a client's CONNACK-refusal slug onto the check's `params.reason`.
 
@@ -156,14 +209,22 @@ async def run_connection_diagnostic(
 
     # --- Port reachability (probed in parallel) ---
     camera_port, camera_protocol = _camera_port_for_printer(printer)
-    mqtt_ok, ftps_ok, camera_ok = await asyncio.gather(
+    mqtt_ok, ftps_state, camera_ok = await asyncio.gather(
         _check_port(ip_address, PORT_MQTT),
-        _check_port(ip_address, PORT_FTPS),
+        _check_ftps_tls(ip_address, getattr(printer, "model", None) if printer else None),
         _check_port(ip_address, camera_port),
     )
     # MQTT is connection-critical; FTPS/camera only degrade printing/camera.
     checks.append(DiagnosticCheck(id="port_mqtt", status="pass" if mqtt_ok else "fail"))
-    checks.append(DiagnosticCheck(id="port_ftps", status="pass" if ftps_ok else "warn"))
+    # "no_tls" gets its own message: the port is open, so the usual advice
+    # (unblock port 990) is wrong and only a printer restart helps (#2780).
+    checks.append(
+        DiagnosticCheck(
+            id="port_ftps",
+            status="pass" if ftps_state == "ok" else "warn",
+            params={} if ftps_state != "no_tls" else {"reason": "no_tls"},
+        )
+    )
     checks.append(
         DiagnosticCheck(
             id="port_rtsps",
@@ -240,11 +301,14 @@ async def run_connection_diagnostic(
     store_to_sdcard = getattr(state, "store_to_sdcard", None) if state else None
     if not model_has_slot or state is None or not state.connected:
         checks.append(DiagnosticCheck(id="external_storage", status="skip"))
-    elif store_to_sdcard is True:
-        checks.append(DiagnosticCheck(id="external_storage", status="pass"))
     elif store_to_sdcard is False and not has_remote_storage_toggle(model):
         # Slot present but no way to enable it on this firmware — don't nag
         # with an unresolvable fail; explain why via the reason param.
+        #
+        # Ahead of the empty-slot check below on purpose (#2524 over #2780):
+        # on a P1-series the toggle cannot be switched on at all, so telling
+        # the operator to insert a card would promise a fix that inserting a
+        # card does not deliver.
         checks.append(
             DiagnosticCheck(
                 id="external_storage",
@@ -252,6 +316,33 @@ async def run_connection_diagnostic(
                 params={"reason": "unsupported_model"},
             )
         )
+    elif getattr(state, "sdcard_reported", False) and not getattr(state, "sdcard", False):
+        # The toggle can be on and still achieve nothing with an empty slot,
+        # and that combination used to report a clean pass — #2780's H2C had
+        # `store_to_sdcard` set and `sdcard` False for three solid weeks while
+        # every one of its archives came out blank. Report the empty slot,
+        # which is the part the operator can actually act on.
+        checks.append(
+            DiagnosticCheck(
+                id="external_storage",
+                status="fail",
+                params={"reason": "no_media"},
+            )
+        )
+    elif not last_print_storage_verdict(state).reachable:
+        # The toggle is on, a card is in, and the printer still put the last
+        # print on internal storage — which is what H2-series and P2S firmware
+        # does, and no setting here changes it (#2762 tracks reading that
+        # storage). A pass here would be a lie; a fail would be unresolvable.
+        checks.append(
+            DiagnosticCheck(
+                id="external_storage",
+                status="warn",
+                params={"reason": REASON_INTERNAL_STORAGE},
+            )
+        )
+    elif store_to_sdcard is True:
+        checks.append(DiagnosticCheck(id="external_storage", status="pass"))
     elif store_to_sdcard is False:
         checks.append(DiagnosticCheck(id="external_storage", status="fail"))
     else:

+ 106 - 14
backend/app/services/printer_manager.py

@@ -238,6 +238,86 @@ def drying_screen_only(model: str | None) -> bool:
     return model.strip().upper() in _DRYING_SCREEN_ONLY_MODELS
 
 
+# Temperature keys the UI actually draws. `state.temperatures` is also working
+# memory: it carries private bookkeeping (`_nozzle_target_set_time`) and derived
+# flags (`nozzle_heating`) that no consumer outside this module should see. The
+# full-status path hands out the whole dict to logged-in callers; the streaming
+# overlay gets only this list, because an overlay token is a narrower grant than
+# a login and should not pick up fields by accident as the dict grows.
+DISPLAY_TEMPERATURE_KEYS = (
+    "nozzle",
+    "nozzle_target",
+    "nozzle_2",
+    "nozzle_2_target",
+    "bed",
+    "bed_target",
+    "chamber",
+    "chamber_target",
+)
+
+
+def display_temperatures(temperatures: dict | None, model: str | None) -> dict[str, float]:
+    """Filter `state.temperatures` down to the readings a viewer is shown.
+
+    Drops chamber readings on models without a real chamber sensor — P1P, P1S,
+    A1 and A1 mini all report a meaningless `chamber_temper` — matching what
+    ``printer_state_to_dict`` already does for the full status payload.
+    """
+    if not temperatures:
+        return {}
+    allow_chamber = supports_chamber_temp(model)
+    out: dict[str, float] = {}
+    for key in DISPLAY_TEMPERATURE_KEYS:
+        if key.startswith("chamber") and not allow_chamber:
+            continue
+        value = temperatures.get(key)
+        if value is None:
+            continue
+        try:
+            out[key] = float(value)
+        except (TypeError, ValueError):
+            continue
+    return out
+
+
+def uniform_tray_filament_hint(loaded_types: list[str]) -> str | None:
+    """Guess an active cycle's filament from the loaded trays.
+
+    Bambu never echoes back which filament or temperature a drying cycle is
+    running, so the badge normally reads the target we cached when we sent the
+    command. This is the fallback for when we have no record — drying started in
+    a previous backend lifetime, or from the printer's own screen.
+
+    It answers only when every loaded tray holds the same filament type. On a
+    mixed unit the first tray is evidence of nothing: an AMS holding two PETG
+    and two PLA spools, drying PLA at the 45°C the user picked, was labelled
+    "PETG @ 65°C" purely because slot 1 happened to be PETG (#2759).
+
+    Deliberately no temperature. The RFID-recommended ``drying_temp`` used to be
+    returned alongside a uniform filament, which narrowed #2759 to units whose
+    spools disagree but left the uniform case stating a temperature just as
+    invented: a unit loaded entirely with PLA, drying at the 45°C the user
+    picked, read "PLA @ 55°C" the moment the cached target went missing. The
+    filament type is real evidence — every spool in the unit agrees on it, and
+    the dryer heats all of them — but the temperature is a free choice in the
+    popover, so a recommendation is never evidence of what is running. The badge
+    shows the filament and the countdown, and names a temperature only when we
+    actually sent it.
+
+    Args:
+        loaded_types: ``tray_type`` for each tray, in slot order. Empty slots
+            (falsy) are ignored.
+
+    Returns:
+        The shared filament type, or None if the loaded trays disagree or the
+        unit is empty.
+    """
+    types = {str(tray_type) for tray_type in loaded_types if tray_type}
+    if len(types) != 1:
+        return None
+    return next(iter(types))
+
+
 def supports_drying(model: str | None, firmware: str | None) -> bool:
     """Check if a printer model accepts remote AMS drying commands.
 
@@ -329,6 +409,7 @@ class PrinterManager:
         self._on_bed_temp_update: Callable[[int, float], None] | None = None
         self._on_drying_complete: Callable[[int, int], None] | None = None
         self._on_assignment_verified: Callable[[int, int, int, bool, dict], None] | None = None
+        self._on_tray_change: Callable[[int, int, int], None] | None = None
         self._loop: asyncio.AbstractEventLoop | None = None
         # Track who started the current print (Issue #206)
         self._current_print_user: dict[int, dict] = {}  # {printer_id: {"user_id": int, "username": str}}
@@ -579,6 +660,15 @@ class PrinterManager:
         """
         self._on_assignment_verified = callback
 
+    def set_tray_change_callback(self, callback: Callable[[int, int, int], None]):
+        """Set callback for mid-print tray changes.
+
+        Receives ``(printer_id, global_tray_id, layer_num)`` for every entry
+        appended to the printer's tray-change log, so it can be persisted for
+        the completion-time weight split.
+        """
+        self._on_tray_change = callback
+
     def _schedule_async(self, coro):
         """Schedule an async coroutine from a sync context.
 
@@ -650,6 +740,10 @@ class PrinterManager:
             if self._on_assignment_verified:
                 self._schedule_async(self._on_assignment_verified(printer_id, ams_id, tray_id, verified, detail))
 
+        def on_tray_change(tray_global: int, layer_num: int):
+            if self._on_tray_change:
+                self._schedule_async(self._on_tray_change(printer_id, tray_global, layer_num))
+
         client = BambuMQTTClient(
             ip_address=printer.ip_address,
             serial_number=printer.serial_number,
@@ -666,6 +760,7 @@ class PrinterManager:
             on_print_running_observed=on_print_running_observed,
             on_finish_photo_moment=on_finish_photo_moment,
             on_assignment_verified=on_assignment_verified,
+            on_tray_change=on_tray_change,
         )
 
         client.connect()
@@ -791,6 +886,7 @@ class PrinterManager:
         use_ams: bool = True,
         nozzle_offset_cali: str = "auto",
         nozzle_mapping: str | None = None,
+        nozzle_slot_extruders: str | None = None,
     ) -> bool:
         """Start a print on a connected printer.
 
@@ -798,6 +894,10 @@ class PrinterManager:
         project_file MQTT command (H2C rack-swap slicer pick preservation,
         #1780). It rides through to the MQTT client untouched; the dispatch
         builder there parses + injects it only on dual-nozzle models.
+
+        ``nozzle_slot_extruders`` is the fallback for a job that never passed
+        through BambuStudio (#2800): per-slot extruder indices the MQTT layer
+        resolves into physical rack positions, and only on rack models.
         """
         caller = traceback.extract_stack(limit=3)[0]
         logger.info(
@@ -821,6 +921,7 @@ class PrinterManager:
                 use_ams=use_ams,
                 nozzle_offset_cali=nozzle_offset_cali,
                 nozzle_mapping=nozzle_mapping,
+                nozzle_slot_extruders=nozzle_slot_extruders,
             )
         return False
 
@@ -1254,9 +1355,9 @@ def printer_state_to_dict(
             # per-tick AMS push, so prefer the cached target from the last
             # ``send_drying_command``. When we have no record (drying
             # started in a previous backend lifetime, or the cache was
-            # never seeded), fall back to the first loaded tray's
-            # tray_type + RFID-recommended drying_temp — the same heuristic
-            # the popover already uses to seed defaults.
+            # never seeded), the loaded trays can still name the filament
+            # if they agree — but never the temperature, which only the
+            # cache knows. See uniform_tray_filament_hint.
             ams_id_int = int(ams_data.get("id", 0))
             target = (drying_targets or {}).get(ams_id_int)
             dry_target_temp: int | None = None
@@ -1271,17 +1372,8 @@ def printer_state_to_dict(
                         dry_target_temp = None
                 if fil_val:
                     dry_filament = str(fil_val)
-            if dry_target_temp is None or not dry_filament:
-                for tray in trays:
-                    if tray.get("tray_type"):
-                        if not dry_filament:
-                            dry_filament = str(tray["tray_type"])
-                        if dry_target_temp is None and tray.get("drying_temp"):
-                            try:
-                                dry_target_temp = int(tray["drying_temp"])
-                            except (TypeError, ValueError):
-                                pass
-                        break
+            if not dry_filament:
+                dry_filament = uniform_tray_filament_hint([tray.get("tray_type") or "" for tray in trays])
 
             ams_units.append(
                 {

+ 109 - 0
backend/app/services/process_overrides.py

@@ -0,0 +1,109 @@
+"""Apply the user's own process-setting choices to an outgoing slice.
+
+Bambuddy's slice modal can edit OrcaSlicer's full process parameter set (layer
+height, wall count, supports, speeds — the same tree the desktop slicer shows
+under Print Settings). Those edits arrive as a sparse ``{key: value}`` map and
+are written into the process JSON that goes out as ``--load-settings``, using
+the same mechanism ``_patch_process_support_settings`` (#1881) and
+``apply_design_overrides`` (#2622) already use.
+
+Precedence is deliberate and is the reason this runs last: the picked preset is
+the base, the source 3MF's support configuration and the designer's own tweaks
+layer on top, and an explicit choice the user made in the modal beats all of
+them. Anything else would silently discard a setting the user just typed.
+
+Values are normalised to the string forms a process preset actually stores
+(``"1"`` for a bool, ``"20%"`` for a percent, a list of strings for the
+per-extruder vector options). The frontend already serialises through the option
+schema, so this is a second line of defence for clients that don't — the slicer
+CLI validates far more strictly than the GUI and a wrongly-typed value fails the
+whole slice rather than being coerced.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import re
+
+logger = logging.getLogger(__name__)
+
+# Config keys are lowercase identifiers. Anything else did not come from the
+# option schema, so it cannot be a real process setting.
+_KEY_RE = re.compile(r"^[a-z][a-z0-9_]*$")
+
+# A process JSON is a flat string map; nesting a structure inside it produces a
+# file the CLI rejects outright.
+_ScalarTypes = (str, int, float, bool)
+
+
+def _normalise_scalar(value: object) -> str | None:
+    """Render one scalar the way a process preset stores it, or ``None`` if it
+    is not a value a process setting can hold."""
+    if isinstance(value, bool):
+        # Checked before int on purpose — bool is a subclass of int, and a
+        # process JSON spells booleans "1"/"0", never "True"/"False".
+        return "1" if value else "0"
+    if isinstance(value, (int, float)):
+        return str(value)
+    if isinstance(value, str):
+        return value
+    return None
+
+
+def normalise_process_overrides(overrides: dict[str, object]) -> dict[str, str | list[str]]:
+    """Filter and normalise a client-supplied override map.
+
+    Keys that don't look like config keys, and values that a process preset
+    cannot hold, are dropped with a warning rather than failing the slice: the
+    user's other settings are still worth applying, and a hard failure here
+    would be reported as "slicing failed" with no clue which field caused it.
+    """
+    clean: dict[str, str | list[str]] = {}
+    for key, value in overrides.items():
+        if not isinstance(key, str) or not _KEY_RE.match(key):
+            logger.warning("Ignoring process override with unusable key: %r", key)
+            continue
+
+        if isinstance(value, list):
+            parts = [_normalise_scalar(v) for v in value]
+            if any(p is None for p in parts):
+                logger.warning("Ignoring process override %s: list contains a non-scalar entry", key)
+                continue
+            clean[key] = [p for p in parts if p is not None]
+            continue
+
+        scalar = _normalise_scalar(value)
+        if scalar is None:
+            logger.warning("Ignoring process override %s: unsupported value type %s", key, type(value).__name__)
+            continue
+        clean[key] = scalar
+
+    return clean
+
+
+def apply_process_overrides(process_json: str, overrides: dict[str, object]) -> str:
+    """Write the user's process settings into the outgoing process JSON.
+
+    Returns ``process_json`` unchanged when there is nothing to apply or the
+    JSON is unparseable, so a bad input degrades to a slice with the picked
+    preset rather than failing it — matching ``apply_design_overrides``.
+    """
+    if not overrides:
+        return process_json
+
+    clean = normalise_process_overrides(overrides)
+    if not clean:
+        return process_json
+
+    try:
+        process_cfg = json.loads(process_json)
+    except json.JSONDecodeError:
+        logger.warning("Process preset JSON is unparseable; skipping %d user override(s)", len(clean))
+        return process_json
+    if not isinstance(process_cfg, dict):
+        return process_json
+
+    process_cfg.update(clean)
+    logger.info("Applying %d user process override(s): %s", len(clean), sorted(clean))
+    return json.dumps(process_cfg)

+ 95 - 0
backend/app/services/slice_output_check.py

@@ -0,0 +1,95 @@
+"""Sanity-check a sliced file before Bambuddy is willing to print it.
+
+A Bambu printer's start G-code is where the AMS load (``M620``) and the
+preparation-stage announcements (``M1002 gcode_claim_action``) live. Slice
+without it and the job still dispatches, still heats the bed and still moves
+the toolhead — it simply extrudes nothing, reports no stage, and sits at layer
+0 until someone notices (#2838).
+
+Nothing downstream can tell that apart from a print that has not started yet,
+so the only place to catch it is here, on the bytes the slicer just produced.
+All 56 instantiable presets in the shipped Bambu bundle carry
+``gcode_claim_action``, which makes its absence a reliable signal rather than
+a heuristic.
+"""
+
+from __future__ import annotations
+
+import io
+import json
+import logging
+import zipfile
+
+logger = logging.getLogger(__name__)
+
+# Present in the start G-code of every instantiable machine preset in the
+# bundle. `M620` is equally universal today, but this one is the marker whose
+# absence the reporter could see from the printer's side: no claim actions
+# means `stg_cur` stays -1 and the UI never names a preparation step.
+_START_GCODE_MARKER = "gcode_claim_action"
+
+_PROJECT_SETTINGS = "Metadata/project_settings.config"
+
+# The start block sits after the file header and the embedded thumbnails, well
+# inside this. Bounded so a pathological output cannot turn the check into a
+# multi-hundred-megabyte read.
+_GCODE_SCAN_BYTES = 4 * 1024 * 1024
+
+
+def _as_text(value: object) -> str:
+    """Slicer config values arrive as a bare string or a one-element list."""
+    if isinstance(value, list):
+        return "".join(str(v) for v in value)
+    return "" if value is None else str(value)
+
+
+def start_gcode_is_missing(content: bytes, *, export_3mf: bool) -> bool:
+    """Whether ``content`` was sliced without the printer's start G-code.
+
+    Answers False whenever the question cannot be settled — an unreadable
+    archive, a missing config, a decode failure. A slice that is merely
+    unusual must not be blocked by a check that only knows how to recognise
+    one specific defect; the caller has no better information than we do.
+    """
+    if not content:
+        return False
+
+    if not export_3mf:
+        head = content[:_GCODE_SCAN_BYTES].decode("utf-8", errors="ignore")
+        return bool(head) and _START_GCODE_MARKER not in head
+
+    try:
+        with zipfile.ZipFile(io.BytesIO(content)) as archive:
+            raw = archive.read(_PROJECT_SETTINGS)
+    except (KeyError, OSError, zipfile.BadZipFile) as exc:
+        logger.debug("Slice output check skipped: cannot read %s (%s)", _PROJECT_SETTINGS, exc)
+        return False
+
+    try:
+        settings = json.loads(raw)
+    except (UnicodeDecodeError, json.JSONDecodeError) as exc:
+        logger.debug("Slice output check skipped: %s is not valid JSON (%s)", _PROJECT_SETTINGS, exc)
+        return False
+
+    if not isinstance(settings, dict) or "machine_start_gcode" not in settings:
+        logger.debug("Slice output check skipped: no machine_start_gcode in %s", _PROJECT_SETTINGS)
+        return False
+
+    return _START_GCODE_MARKER not in _as_text(settings["machine_start_gcode"])
+
+
+def missing_start_gcode_message(printer_preset_name: str) -> str:
+    """The 502 body for a slice that came back without its start G-code.
+
+    Names the sidecar because that is where the fix is: Bambuddy sends the
+    bundled preset by name and the sidecar resolves it, so an older image
+    resolves it to a generic 577-character stub and no amount of retrying in
+    Bambuddy will change the result.
+    """
+    return (
+        f"The slicer returned a file with no printer start G-code for '{printer_preset_name}'. "
+        "Printing it would heat the printer and extrude nothing, so it was not saved. "
+        "This is fixed by updating the slicer sidecar image: older ones cannot read the "
+        "companion profile that holds the real start G-code for most Bambu printers. "
+        "Update the sidecar and slice again."
+    )

+ 191 - 12
backend/app/services/slice_preview.py

@@ -10,7 +10,14 @@ This module wraps the sidecar's slice call so the endpoint can run a preview
 slice, parse the result's slice_info, and return the actual filament list.
 The preview always uses the file's embedded settings (``slice_without_profiles``):
 the slot-mapping is a model property, independent of process settings, so
-we don't need to thread the user's profile triplet through here.
+we don't need to thread the user's profile triplet through here. That choice
+also protects the numbers — overriding the process preset drops the project's
+own support configuration, which loses whole slots from the answer.
+
+The one thing that can defeat those embedded settings is a custom G-code
+template written by a Studio newer than the sidecar, which fails to parse
+before any slice_info exists. That case gets one retry with the offending
+template blanked; see ``_blank_custom_gcode``.
 
 Results are cached by ``(kind, source_id, plate_id, content_hash)`` so
 repeat opens on the same plate are instant. LRU eviction keeps the cache
@@ -22,7 +29,9 @@ from __future__ import annotations
 
 import asyncio
 import hashlib
+import json
 import logging
+import re
 import zipfile
 from collections import OrderedDict
 from io import BytesIO
@@ -36,6 +45,51 @@ from backend.app.services.slicer_api import (
 
 logger = logging.getLogger(__name__)
 
+_PROJECT_SETTINGS_PATH = "Metadata/project_settings.config"
+
+# The slicer names the offending G-code field in its stderr, e.g.
+#   timelapse_gcode Parsing error at line 13: Not a variable name
+#       {if timelapse_inline_photo}
+_GCODE_PARSE_ERROR_RE = re.compile(
+    r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s+Parsing error at line \d+:",
+    re.MULTILINE,
+)
+
+# Custom G-code fields we are willing to blank to get a preview through.
+#
+# Deliberately narrow, and the narrowness is the whole point: blanking a
+# field that *extrudes* would change the very numbers the preview exists to
+# report. `machine_start_gcode` lays a prime line, `change_filament_gcode`
+# purges — silence either and the returned grams are quietly wrong, which is
+# worse than returning nothing. Everything below only moves the toolhead or
+# emits markers, so removing it cannot alter filament accounting. Verified
+# against a real H2D slice: blanking `time_lapse_gcode` left every
+# used_g/used_m in slice_info byte-identical.
+#
+# Keys are normalised (see `_normalise_option`) because the slicer reports
+# `timelapse_gcode` while the 3MF stores `time_lapse_gcode`.
+_BLANKABLE_GCODE_FIELDS = frozenset(
+    {
+        "timelapsegcode",
+        "layerchangegcode",
+        "beforelayerchangegcode",
+        "machinepausegcode",
+        "templatecustomgcode",
+        "printingbyobjectgcode",
+    }
+)
+
+
+def _normalise_option(name: str) -> str:
+    """Fold a config-option name to a comparable form.
+
+    Bambu Studio's error text and its 3MF config disagree on word breaks for
+    the same option (`timelapse_gcode` vs `time_lapse_gcode`), so matching on
+    the literal string silently fails to find the field it just named.
+    """
+    return re.sub(r"[^a-z0-9]", "", name.lower())
+
+
 _PREVIEW_CACHE_MAX = 256
 _PreviewCacheKey = tuple[str, int, int, str]
 # Cache values: list[dict] on success, [] on parsed-but-empty (slicer
@@ -54,6 +108,85 @@ def _content_hash(file_bytes: bytes) -> str:
     return hashlib.sha256(file_bytes).hexdigest()[:16]
 
 
+def _unparsable_gcode_option(error_text: str) -> str | None:
+    """The normalised name of the custom-G-code field the slicer choked on.
+
+    Returns ``None`` when the failure was something else entirely, or when the
+    named field is one whose removal could change filament accounting — see
+    ``_BLANKABLE_GCODE_FIELDS``. Callers treat ``None`` as "don't retry".
+    """
+    match = _GCODE_PARSE_ERROR_RE.search(error_text)
+    if match is None:
+        return None
+    option = _normalise_option(match.group(1))
+    return option if option in _BLANKABLE_GCODE_FIELDS else None
+
+
+def _blank_custom_gcode(file_bytes: bytes, option: str) -> bytes | None:
+    """Return a copy of the 3MF with ``option``'s G-code template emptied.
+
+    A 3MF saved by a newer Bambu Studio can carry a machine G-code template
+    that references a config variable an older sidecar doesn't define — e.g.
+    Studio 2.8 writes ``{if timelapse_inline_photo}`` into ``time_lapse_gcode``
+    without exporting a definition for it, so the template is unresolvable the
+    moment it leaves Studio. Slicing then dies with a placeholder parse error
+    before producing any slice_info, and the preview has nothing to read.
+
+    Emptying just the one named template lets the slice complete on the file's
+    own settings, which is what keeps the answer trustworthy: process settings,
+    support configuration and per-slot filament assignments are all preserved,
+    so the filament list matches what the file would really produce.
+
+    Returns ``None`` when there is nothing to do — not a 3MF, no embedded
+    settings, no matching field, or a field that is already empty — so the
+    caller can skip a retry that would fail identically.
+    """
+    try:
+        with zipfile.ZipFile(BytesIO(file_bytes)) as zf:
+            if _PROJECT_SETTINGS_PATH not in zf.namelist():
+                return None
+            entries = [(info, zf.read(info.filename)) for info in zf.infolist()]
+            settings = json.loads(zf.read(_PROJECT_SETTINGS_PATH).decode("utf-8", "replace"))
+    except (zipfile.BadZipFile, OSError, UnicodeDecodeError, json.JSONDecodeError):
+        return None
+    if not isinstance(settings, dict):
+        return None
+
+    # Match on the normalised name so the slicer's spelling finds the config's.
+    # Only `*_gcode` keys are eligible, so a same-stem non-template setting
+    # can never be caught by the fold.
+    blanked: list[str] = []
+    for key, value in settings.items():
+        if not key.endswith("_gcode") or _normalise_option(key) != option:
+            continue
+        if isinstance(value, str) and value:
+            settings[key] = ""
+        elif isinstance(value, list) and any(value):
+            # Preserve the container type — a per-extruder template is a list,
+            # and handing the CLI a bare string where it expects one would
+            # trade this parse error for a different one.
+            settings[key] = [""] * len(value)
+        else:
+            continue
+        blanked.append(key)
+    if not blanked:
+        return None
+
+    out = BytesIO()
+    try:
+        with zipfile.ZipFile(out, "w") as zf_out:
+            for info, data in entries:
+                if info.filename == _PROJECT_SETTINGS_PATH:
+                    data = json.dumps(settings, indent=4).encode("utf-8")
+                # Carry each member's original compression across so the copy
+                # stays a 3MF the slicer reads the same way as the original.
+                zf_out.writestr(info, data, compress_type=info.compress_type)
+    except (OSError, ValueError):
+        return None
+    logger.debug("Preview slice: emptied custom G-code field(s) %s for retry", ", ".join(blanked))
+    return out.getvalue()
+
+
 async def get_preview_filaments(
     *,
     kind: str,
@@ -70,7 +203,8 @@ async def get_preview_filaments(
 
     Uses the file's embedded settings (``slice_without_profiles``) since the
     slot mapping is a model property, independent of any user-picked profile
-    triplet.
+    triplet. A slice killed by an unparsable custom G-code template is retried
+    once with that template blanked, still on the file's own settings.
 
     Returns ``None`` when the preview slice fails — the caller should fall
     back to whatever heuristic it has (typically the project_filaments +
@@ -92,28 +226,73 @@ async def get_preview_filaments(
             _preview_cache.move_to_end(key)
             return cached
 
-        try:
-            # Preview slices are bounded the same way as real ones (#2730):
-            # a heavy plate can take a long time and must not be cut off
-            # while the slicer is visibly working.
-            svc_kwargs = {} if timeout_seconds is None else {"timeout_seconds": timeout_seconds}
+        # Preview slices are bounded the same way as real ones (#2730):
+        # a heavy plate can take a long time and must not be cut off
+        # while the slicer is visibly working.
+        svc_kwargs = {} if timeout_seconds is None else {"timeout_seconds": timeout_seconds}
+
+        async def _slice(model_bytes: bytes):
             async with SlicerApiService(base_url=api_url, **svc_kwargs) as svc:
-                result = await svc.slice_without_profiles(
-                    model_bytes=file_bytes,
+                return await svc.slice_without_profiles(
+                    model_bytes=model_bytes,
                     model_filename=file_name,
                     plate=plate_id,
                     export_3mf=True,
                     request_id=request_id,
                 )
+
+        try:
+            result = await _slice(file_bytes)
         except SlicerApiError as e:
-            logger.warning(
-                "Preview slice failed for %s/%s plate %s: %s",
+            # One retry, and only for a custom-G-code template the sidecar
+            # cannot parse — a file from a Studio newer than the sidecar. The
+            # alternative is to give the caller nothing and let it fall back to
+            # its painted-face heuristic, so a retry that reproduces the file's
+            # own settings is strictly better than the status quo. Anything
+            # else (unreachable sidecar, timeout, bad input) returns as before.
+            #
+            # Whether a retry is even possible is decided *before* anything is
+            # logged, so a slice that recovers never announces itself as a
+            # failure. Logging the first attempt at WARNING regardless sent a
+            # reader looking for a bug in a path that had already fixed itself
+            # twenty seconds later, several screens further down the log.
+            retry_bytes = None
+            option = _unparsable_gcode_option(str(e))
+            if option is not None:
+                retry_bytes = _blank_custom_gcode(file_bytes, option)
+            if retry_bytes is None:
+                logger.warning(
+                    "Preview slice failed for %s/%s plate %s: %s",
+                    kind,
+                    source_id,
+                    plate_id,
+                    e,
+                )
+                return None
+            logger.info(
+                "Preview slice for %s/%s plate %s hit unparsable custom G-code; retrying without it. "
+                "The file's G-code references a setting this slicer build does not know, so it is "
+                "probably from a newer Bambu Studio than the sidecar. Original failure: %s",
                 kind,
                 source_id,
                 plate_id,
                 e,
             )
-            return None
+            try:
+                result = await _slice(retry_bytes)
+            except SlicerApiError as retry_exc:
+                logger.warning(
+                    "Preview slice retry without the unparsable G-code also failed for %s/%s plate %s: %s",
+                    kind,
+                    source_id,
+                    plate_id,
+                    retry_exc,
+                )
+                return None
+            except Exception as retry_exc:  # noqa: BLE001 — never break the modal on sidecar issues
+                logger.warning("Preview slice retry unexpected error: %s", retry_exc)
+                return None
+            logger.info("Preview slice for %s/%s plate %s succeeded on retry", kind, source_id, plate_id)
         except Exception as e:  # noqa: BLE001 — never break the modal on sidecar issues
             logger.warning("Preview slice unexpected error: %s", e)
             return None

+ 233 - 12
backend/app/services/slicer_api.py

@@ -10,6 +10,7 @@ under the hood, response body is raw G-code or 3MF with metadata in the
 
 import asyncio
 import io
+import json
 import logging
 import time
 import zipfile
@@ -53,6 +54,18 @@ class SlicerTimeoutError(SlicerApiError):
     """
 
 
+class ResolvedProfile(NamedTuple):
+    """A preset's effective values, or why they are unavailable.
+
+    ``reason`` is one of ``ok`` / ``sidecar_outdated`` / ``sidecar_unavailable``
+    / ``preset_unresolved``. It exists so the UI can say something actionable
+    instead of one generic "could not read the values" for four causes.
+    """
+
+    values: dict | None
+    reason: str
+
+
 class SliceResult(NamedTuple):
     """Result of a slice operation."""
 
@@ -119,7 +132,96 @@ def _format_sidecar_error(response: httpx.Response) -> str:
     return (message or details or response.text)[:500]
 
 
-def _handle_slice_response(response: httpx.Response, *, export_3mf: bool) -> SliceResult:
+def _transport_error_reason(exc: httpx.RequestError) -> str:
+    """Describe a transport failure, even when the exception carries no message.
+
+    Several ``httpx.RequestError`` subclasses are raised with no args, so
+    ``str(exc)`` is the empty string — which is how three lines of the #2802
+    reporter's support package came to read ``Slicer sidecar unreachable:``
+    with nothing after the colon. The class name is not much, but it
+    distinguishes a refused connection from a protocol error, and a log line
+    that names nothing is worth less than one that names the exception type.
+    """
+    return str(exc) or type(exc).__name__
+
+
+# How the sidecar says "your model is bigger than my cap", across versions.
+# Images built before the cap became configurable answer with multer's raw
+# ``LIMIT_FILE_SIZE`` text under a **500** — ``MulterError`` is not the
+# sidecar's ``AppError``, so its handler falls through to the default status —
+# while current ones send a 413 naming the limit and the env var that raises
+# it. Matching on text rather than status covers both, and matters because a
+# 500 otherwise reads as a slicer crash and sends people off tuning reverse
+# proxies that were never in the path (#2802).
+#
+# Deliberately specific: a proxy's own "413 Request Entity Too Large" must NOT
+# match, because that one really is fixed at the proxy and gets its own advice.
+_UPLOAD_TOO_LARGE_MARKERS = (
+    "file too large",
+    "upload limit",
+    "max_model_upload_mb",
+)
+
+# A sidecar that says which knob raises the cap is new enough to have one.
+# Older ones only ever emit multer's bare "File too large", and for those the
+# advice has to be "update the image" — there is no env var to set.
+_CONFIGURABLE_CAP_MARKERS = ("upload limit", "max_model_upload_mb")
+
+
+def _upload_size_rejection(response: httpx.Response, model_size_bytes: int | None) -> str | None:
+    """Return an explanation if the sidecar refused the upload as oversized.
+
+    The 500 case is matched strictly — the body has to be *only* multer's
+    message — because a 500 is also how a genuine CLI failure arrives, and
+    those must keep reaching the embedded-settings fallback. A CLI failure
+    always carries the slicer's stderr in ``details``, so it never reduces to
+    the bare string on its own.
+    """
+    detail = _format_sidecar_error(response)
+    lowered = detail.lower()
+    if response.status_code >= 500:
+        if lowered.strip() != "file too large":
+            return None
+    elif not any(marker in lowered for marker in _UPLOAD_TOO_LARGE_MARKERS):
+        return None
+
+    size = f"{model_size_bytes / (1024 * 1024):.0f} MB " if model_size_bytes else ""
+    # Shared preamble: both variants must rule out the layers people reach for
+    # first, because those are the ones that look like they should apply.
+    common = (
+        f"The slicer sidecar refused the {size}model file as too large. The limit lives inside "
+        "the sidecar container, so it is neither a Bambuddy setting nor a reverse-proxy one — "
+        "raising 'client_max_body_size' or a proxy body limit will not change it."
+    )
+
+    if any(marker in lowered for marker in _CONFIGURABLE_CAP_MARKERS):
+        return (
+            f"{common} Raise it by setting MAX_MODEL_UPLOAD_MB on the slicer-api service and "
+            f"restarting it. Sidecar said: {detail}"
+        )
+    # Naming the service in the compose commands is not a style choice. The
+    # Bambu Studio sidecar sits behind `profiles: [bambu]`, and a bare
+    # `docker compose pull` silently skips every profile-gated service — so the
+    # update this message asks for was a no-op for exactly the users who need
+    # it, and `restart: unless-stopped` kept the old container serving (#2802,
+    # second round). Naming a service enables its profile implicitly, for both
+    # pull and up. `--profile bambu` would also work, but on an OrcaSlicer-only
+    # host it downloads the 220 MB Bambu image and then *starts* a sidecar the
+    # user never asked for.
+    return (
+        f"{common} This sidecar image predates the configurable cap and is fixed at 100 MB. "
+        "Update it with 'cd slicer-api/ && docker compose pull orca-slicer-api && "
+        "docker compose up -d orca-slicer-api', substituting 'bambu-studio-api' if that is the "
+        "sidecar you slice with. Name the service in both commands — a bare 'docker compose pull' "
+        "skips the Bambu Studio sidecar, because it sits behind a compose profile. The new image "
+        "defaults to 512 MB and adds MAX_MODEL_UPLOAD_MB for going higher still. "
+        f"Sidecar said: {detail}"
+    )
+
+
+def _handle_slice_response(
+    response: httpx.Response, *, export_3mf: bool, model_size_bytes: int | None = None
+) -> SliceResult:
     """Turn a sidecar ``/slice`` HTTP response into a validated ``SliceResult``.
 
     Shared by ``slice_with_profiles`` / ``slice_without_profiles`` so the status
@@ -138,6 +240,14 @@ def _handle_slice_response(response: httpx.Response, *, export_3mf: bool) -> Sli
         SlicerInputError: 4xx from the sidecar (bad input / proxy body limit).
         SlicerApiServerError: 5xx, or a 2xx whose body is not a valid 3MF.
     """
+    # Checked ahead of the status branches because the same rejection arrives
+    # as a 500 from older sidecars and a 413 from newer ones, and because
+    # raising SlicerInputError (rather than SlicerApiServerError) is what stops
+    # the library route retrying the identical oversized upload with embedded
+    # settings — a second 25-second conversion for a guaranteed same answer.
+    oversized = _upload_size_rejection(response, model_size_bytes)
+    if oversized:
+        raise SlicerInputError(oversized)
     if response.status_code == 413:
         # A 413 almost never comes from the slicer itself — it's a reverse proxy
         # (nginx/SWAG/Traefik) or a CDN capping the multipart upload (model +
@@ -304,11 +414,70 @@ class SlicerApiService:
         try:
             response = await self._client.get(f"{self.base_url}/health", timeout=10.0)
         except httpx.RequestError as exc:
-            raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
+            raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {_transport_error_reason(exc)}") from exc
         if response.status_code >= 400:
             raise SlicerApiUnavailableError(f"Slicer sidecar /health returned {response.status_code}")
         return response.json()
 
+    async def resolve_profile(self, profile_json: str, category: str) -> "ResolvedProfile":
+        """POST /profiles/resolve — flatten a preset's ``inherits:`` chain.
+
+        Returns the effective key/value map the slicer would actually use, so
+        the slice modal's settings panel can show a preset's real values rather
+        than the option schema's compiled-in defaults (a "Standard" pick is
+        only a ``{inherits: ...}`` stub on our side; everything else it sets
+        lives in the sidecar's bundled profiles).
+
+        This deliberately asks the sidecar rather than resolving locally.
+        Bambuddy has its own ``inherits:`` resolver in ``orca_profiles``, but it
+        walks OrcaSlicer's *published* profile tree, which is not necessarily
+        the one baked into the running sidecar image — values from it would look
+        authoritative and could quietly disagree with what gets sliced.
+
+        Returns a :class:`ResolvedProfile` whose ``reason`` distinguishes *why*
+        values are missing. That matters more than it looks: the common case in
+        practice is a sidecar older than this endpoint, because a Bambuddy
+        install pulls ``SIDECAR_TAG:-latest`` independently of its own release
+        channel. "Could not read the values" sends that user hunting; "your
+        sidecar image is older than this feature" is a one-line fix. Genuine
+        transport failures still raise.
+        """
+        try:
+            payload = json.loads(profile_json)
+        except json.JSONDecodeError:
+            logger.warning("Cannot resolve %s preset: content is not valid JSON", category)
+            return ResolvedProfile(None, "preset_unresolved")
+
+        try:
+            response = await self._client.post(
+                f"{self.base_url}/profiles/resolve",
+                json={"category": category, "profile": payload},
+                timeout=15.0,
+            )
+        except httpx.RequestError as exc:
+            raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {_transport_error_reason(exc)}") from exc
+
+        if response.status_code == 404:
+            # Sidecar predates the endpoint. Not an error, and specifically not
+            # the same as a broken one — this is the case that has a fix the
+            # user can act on.
+            logger.info("Slicer sidecar has no /profiles/resolve; falling back to schema defaults")
+            return ResolvedProfile(None, "sidecar_outdated")
+        if response.status_code >= 400:
+            logger.warning(
+                "Slicer sidecar /profiles/resolve returned %s: %s",
+                response.status_code,
+                _format_sidecar_error(response),
+            )
+            return ResolvedProfile(None, "sidecar_unavailable")
+
+        body = response.json()
+        resolved = body.get("profile") if isinstance(body, dict) else None
+        if not isinstance(resolved, dict):
+            logger.warning("Slicer sidecar /profiles/resolve returned no profile object")
+            return ResolvedProfile(None, "sidecar_unavailable")
+        return ResolvedProfile(resolved, "ok")
+
     async def list_bundled_profiles(self) -> dict:
         """GET /profiles/bundled — return the slicer's stock profiles by slot.
 
@@ -325,7 +494,7 @@ class SlicerApiService:
         try:
             response = await self._client.get(f"{self.base_url}/profiles/bundled", timeout=10.0)
         except httpx.RequestError as exc:
-            raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
+            raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {_transport_error_reason(exc)}") from exc
         if response.status_code >= 400:
             raise SlicerApiUnavailableError(f"Slicer sidecar /profiles/bundled returned {response.status_code}")
         return response.json()
@@ -458,7 +627,7 @@ class SlicerApiService:
         try:
             return post_task.result()
         except httpx.RequestError as exc:
-            raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
+            raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {_transport_error_reason(exc)}") from exc
 
     async def slice_with_profiles(
         self,
@@ -471,6 +640,7 @@ class SlicerApiService:
         plate: int | None = None,
         export_3mf: bool = False,
         arrange: bool = False,
+        orient: bool = False,
         request_id: str | None = None,
         on_progress: Callable[[dict], None] | None = None,
     ) -> SliceResult:
@@ -489,7 +659,15 @@ class SlicerApiService:
         the source's X1C-coordinate layout would otherwise drop into an H2D
         dead zone or trigger the multi-extruder geometry pipeline's polygon
         clipping crash. Default off so single-printer slices preserve the
-        user's deliberate layout.
+        user's deliberate layout. Also settable per-slice by the user
+        (#2548).
+
+        ``orient`` forwards ``--orient``, the CLI's auto-orientation pass:
+        the slicer scores candidate rotations (overhang area, contour,
+        unprintability) and rotates each object onto the best one before
+        slicing. User-driven only — nothing in Bambuddy turns it on by
+        itself, since rotating a deliberately-laid-out model is not a
+        change to make silently.
 
         ``request_id``: when supplied, the sidecar wires --pipe to a
         per-request FIFO and publishes structured JSON progress events to
@@ -522,11 +700,7 @@ class SlicerApiService:
             data["plate"] = str(plate)
         if export_3mf:
             data["exportType"] = "3mf"
-        if arrange:
-            # Sidecar reads non-empty truthy strings as True; only send the
-            # field when we want the flag on, so default-off callers exactly
-            # match the previous wire payload.
-            data["arrange"] = "true"
+        _add_layout_flags(data, arrange=arrange, orient=orient)
         if request_id is not None:
             data["requestId"] = request_id
 
@@ -535,8 +709,9 @@ class SlicerApiService:
         # and surfaces structured updates via on_progress. Uses a
         # short-tick poll (1s) since the slicer emits stage changes
         # several times per minute on complex models.
+        _log_slice_request(model_filename, model_bytes, plate=plate, profiles=len(filament_profile_jsons) + 2)
         response = await self._post_slice(files=files, data=data, request_id=request_id, on_progress=on_progress)
-        return _handle_slice_response(response, export_3mf=export_3mf)
+        return _handle_slice_response(response, export_3mf=export_3mf, model_size_bytes=len(model_bytes))
 
     async def slice_without_profiles(
         self,
@@ -545,6 +720,8 @@ class SlicerApiService:
         model_filename: str,
         plate: int | None = None,
         export_3mf: bool = False,
+        arrange: bool = False,
+        orient: bool = False,
         request_id: str | None = None,
         on_progress: Callable[[dict], None] | None = None,
     ) -> SliceResult:
@@ -563,6 +740,14 @@ class SlicerApiService:
         events to the ProgressStore so the modal's inline spinner +
         toast can show "Generating G-code (75%)" for that preview as
         well.
+
+        ``arrange`` / ``orient`` mean the same as on
+        ``slice_with_profiles``: they are CLI actions applied to the loaded
+        geometry, independent of where the print config came from. Both
+        paths accept them so a user's per-slice choice survives the
+        embedded-settings route and the segfault fallback — the filament-
+        discovery preview leaves them off, since moving objects there
+        would change nothing about which slots the plate consumes.
         """
         files = {
             "file": (model_filename, model_bytes, _guess_model_content_type(model_filename)),
@@ -572,6 +757,7 @@ class SlicerApiService:
             data["plate"] = str(plate)
         if export_3mf:
             data["exportType"] = "3mf"
+        _add_layout_flags(data, arrange=arrange, orient=orient)
         if request_id is not None:
             data["requestId"] = request_id
 
@@ -580,8 +766,43 @@ class SlicerApiService:
         # embedded-settings fallback path triggered by an Orca/Bambu CLI
         # segfault on complex H2D models — both want to keep updating
         # the user's toast through the slow operation.
+        _log_slice_request(model_filename, model_bytes, plate=plate, profiles=0)
         response = await self._post_slice(files=files, data=data, request_id=request_id, on_progress=on_progress)
-        return _handle_slice_response(response, export_3mf=export_3mf)
+        return _handle_slice_response(response, export_3mf=export_3mf, model_size_bytes=len(model_bytes))
+
+
+def _log_slice_request(filename: str, model_bytes: bytes, *, plate: int | None, profiles: int) -> None:
+    """Record what is being sent to the sidecar, size included.
+
+    Nothing used to log the payload size, so a support package from a slice
+    that failed on an upload cap looked identical to one that failed on a bad
+    profile — #2802 had to be sized by probing a sidecar by hand. One line per
+    slice is cheap next to the operation it describes.
+    """
+    logger.info(
+        "Slicing %s (%.1f MB) plate=%s with %d profile(s)",
+        filename,
+        len(model_bytes) / (1024 * 1024),
+        "all" if plate is None else plate,
+        profiles,
+    )
+
+
+def _add_layout_flags(data: dict[str, str], *, arrange: bool, orient: bool) -> None:
+    """Set the sidecar's ``arrange`` / ``orient`` form fields, but only when on.
+
+    The sidecar branches on ``settings.arrange !== undefined`` and forwards
+    ``--arrange 1`` / ``--arrange 0`` accordingly — but multipart fields
+    arrive as *strings*, and ``"false"`` is truthy in JavaScript. Sending
+    ``"false"`` would therefore turn the flag ON. So an off flag is
+    expressed by omitting the field entirely, which also keeps the wire
+    payload of default-off callers byte-identical to before these
+    parameters existed.
+    """
+    if arrange:
+        data["arrange"] = "true"
+    if orient:
+        data["orient"] = "true"
 
 
 def _safe_int(value: str | None) -> int:

+ 223 - 7
backend/app/services/spoolman_tracking.py

@@ -26,6 +26,19 @@ logger = logging.getLogger(__name__)
 _ZERO_UUID = "00000000000000000000000000000000"
 _ZERO_TAG_UID = "0000000000000000"
 
+# Highest global tray id that names a real slot. 255 does not: it is
+# ``PrinterState.tray_now``'s initial value, what an unparseable reading falls
+# back to, and what the field reads while nothing is loaded. The external spool
+# reports 254 when it is actually in use, and ``bambu_mqtt`` applies the same
+# cut-off when it seeds the tray-change log. Treating 255 as a slot would put
+# ``(255, 1)`` into the "slots this print used" evidence and exclude every real
+# one -- silently disabling the very fallback this guard protects (#1820).
+#
+# Applied to ``tray_now`` only. A 255 in the print's mapping or its tray-change
+# log was written there by a print and is evidence, however odd; a 255 in
+# ``tray_now`` is the field at rest, which is the absence of evidence.
+_MAX_REAL_TRAY_ID = 254
+
 
 def _is_non_zero_identifier(value: str) -> bool:
     """Return True when identifier is non-empty and not all zeros."""
@@ -150,6 +163,62 @@ def _resolve_global_tray_id(slot_id: int, slot_to_tray: list | None, ams_trays:
     return slot_id - 1
 
 
+def _resolve_slot_to_tray_fallback(printer_id: int, filament_usage: list[dict]) -> tuple[list[int] | None, str]:
+    """Recover a slot-to-tray mapping at completion when print start captured none.
+
+    ``store_print_data`` can only learn the mapping from two sources: the
+    ``ams_mapping`` Bambuddy intercepts on the printer's local request topic, and
+    a queue item's stored mapping. Neither exists for a print dispatched from
+    Bambu Studio while the printer is cloud-bound — the command travels through
+    Bambu's broker and never appears on the local topic we subscribe to. With
+    ``slot_to_tray`` left NULL, ``_resolve_global_tray_id`` guesses by position:
+    slicer slot 1 to the first loaded tray, slot 2 to the second, and so on. An
+    AMS that isn't loaded in slicer order then charges every slot to the wrong
+    spool, and the archive's filament is rewritten to match, so the print
+    silently changes colour when it finishes (#2768).
+
+    The printer knows the real answer. Its ``mapping`` field carries the actual
+    slot-to-tray assignment for the running job, and for the models that never
+    publish it (A1, P1S, P2S) the 3MF's per-slot colours can be matched against
+    the loaded trays instead. The built-in inventory writer has consulted both
+    for as long as it has resolved mappings at completion; this gives the
+    Spoolman writer the same two fallbacks at the same moment.
+
+    Deliberately at completion rather than inside ``store_print_data``: the
+    printer keeps publishing ``mapping`` long after a job ends — it is still in
+    the status payload while the printer sits idle — so reading it at print start
+    risks stamping the *previous* job's mapping onto this one before the printer
+    has pushed the update. At completion the field unambiguously describes the
+    job that just ran.
+
+    Args:
+        printer_id: Printer whose live state is consulted.
+        filament_usage: The 3MF's per-slot estimates, needed by the colour
+            match. Only the ``slot_id``/``color`` keys are read.
+
+    Returns:
+        ``(mapping, source)``, or ``(None, "none")`` when neither fallback
+        produced anything and the positional default stands.
+    """
+    from backend.app.services.printer_manager import printer_manager
+    from backend.app.services.usage_tracker import _decode_mqtt_mapping, _match_slots_by_color
+
+    state = printer_manager.get_status(printer_id)
+    raw_data = getattr(state, "raw_data", None) if state else None
+    if not raw_data:
+        return None, "none"
+
+    decoded = _decode_mqtt_mapping(raw_data.get("mapping"))
+    if decoded:
+        return decoded, "mqtt"
+
+    matched = _match_slots_by_color(filament_usage, raw_data.get("ams"))
+    if matched:
+        return matched, "color_match"
+
+    return None, "none"
+
+
 def build_ams_tray_lookup(raw_data: dict) -> dict[int, dict]:
     """Build lookup of global_tray_id -> tray info from printer state.
 
@@ -182,7 +251,7 @@ def build_ams_tray_lookup(raw_data: dict) -> dict[int, dict]:
     return lookup
 
 
-def _snapshot_tray_remain(raw_data: dict) -> dict[str, dict]:
+def _snapshot_tray_remain(raw_data: dict, skipped_out: list[str] | None = None) -> dict[str, dict]:
     """Capture per-slot ``remain%`` + ``tray_uuid`` at print start so the
     completion path can compute a remain-delta when 3MF data doesn't cover
     the slot (or there's no 3MF at all — #1820).
@@ -192,6 +261,12 @@ def _snapshot_tray_remain(raw_data: dict) -> dict[str, dict]:
     values mean the AMS hasn't read the spool yet and a delta would be
     meaningless. Mirrors the gate in
     ``usage_tracker.on_print_start:309``.
+
+    A rejected slot is appended to *skipped_out* when one is supplied, so the
+    caller can say which slots this print will not be able to charge. That is
+    not hypothetical: an AMS reports a negative ``remain`` on a nearly empty
+    spool, so the gate can drop the one slot that is about to do the printing
+    (#1820).
     """
     snapshot: dict[str, dict] = {}
     ams_raw = raw_data.get("ams", [])
@@ -210,6 +285,8 @@ def _snapshot_tray_remain(raw_data: dict) -> dict[str, dict]:
                     "remain": remain,
                     "tray_uuid": tray.get("tray_uuid", "") or "",
                 }
+            elif skipped_out is not None:
+                skipped_out.append(f"AMS{ams_id}-T{tray_id}(remain={remain})")
     vt_tray_raw = raw_data.get("vt_tray") or []
     if isinstance(vt_tray_raw, dict):
         vt_tray_raw = [vt_tray_raw]
@@ -225,6 +302,8 @@ def _snapshot_tray_remain(raw_data: dict) -> dict[str, dict]:
                 "remain": remain,
                 "tray_uuid": vt.get("tray_uuid", "") or "",
             }
+        elif skipped_out is not None:
+            skipped_out.append(f"VT{vt_id}(remain={remain})")
     return snapshot
 
 
@@ -273,7 +352,17 @@ async def store_print_data(
     tray_remain_start: dict[str, dict] = {}
     if state and state.raw_data:
         ams_trays = build_ams_tray_lookup(state.raw_data)
-        tray_remain_start = _snapshot_tray_remain(state.raw_data)
+        skipped_slots: list[str] = []
+        tray_remain_start = _snapshot_tray_remain(state.raw_data, skipped_slots)
+        if skipped_slots:
+            # Matches what usage_tracker.on_print_start reports for the
+            # internal inventory, so both backends name the slots that this
+            # print will not be able to charge at AMS granularity.
+            logger.info(
+                "[SPOOLMAN] Printer %s: slots with no usable remain%% at print start: %s",
+                printer_id,
+                ", ".join(skipped_slots),
+            )
 
     # Try to read per-slot filament estimates from the 3MF. Two paths can
     # leave ``filament_usage`` empty: (1) fallback archive (no .gcode.3mf
@@ -305,7 +394,7 @@ async def store_print_data(
         )
         filament_usage = extract_filament_usage_from_3mf(full_path, effective_plate_id) or None
 
-        layer_usage = extract_layer_filament_usage_from_3mf(full_path)
+        layer_usage = extract_layer_filament_usage_from_3mf(full_path, effective_plate_id)
         if layer_usage:
             # Convert int keys to string for JSON serialization
             layer_usage_json = {str(k): v for k, v in layer_usage.items()}
@@ -327,9 +416,11 @@ async def store_print_data(
     # Prefer the explicit mapping captured from the print command, then fall back
     # to any queue mapping stored for scheduled/reprint jobs.
     slot_to_tray = ams_mapping if ams_mapping is not None else None
+    mapping_source = "print_cmd" if slot_to_tray else None
     if not slot_to_tray and queue_item and queue_item.ams_mapping:
         try:
             slot_to_tray = json.loads(queue_item.ams_mapping)
+            mapping_source = "queue"
         except json.JSONDecodeError:
             pass  # Ignore malformed AMS mapping; fall back to default slot assignment
 
@@ -351,6 +442,11 @@ async def store_print_data(
         layer_usage=layer_usage_json,
         filament_properties=filament_properties,
         tray_remain_start=tray_remain_start or None,
+        # Which slot the printer was drawing from when this print began. For a
+        # print with no ams_mapping -- one started from the printer's own
+        # screen, which is the case this whole fallback exists for -- it is the
+        # only evidence of which slot the print used (#1820).
+        tray_now_at_start=getattr(state, "tray_now", None) if state else None,
     )
     db.add(tracking)
     await db.commit()
@@ -364,8 +460,15 @@ async def store_print_data(
     )
     logger.debug("[SPOOLMAN] Filament usage: %s", filament_usage)
     logger.debug("[SPOOLMAN] AMS trays: %s", list(ams_trays.keys()))
-    if slot_to_tray:
-        logger.debug("[SPOOLMAN] Custom slot mapping: %s", slot_to_tray)
+    # Logged at info even when there is no mapping: "source: none" here is the
+    # signal that completion will have to fall back, which is the single most
+    # useful line in the log when a print is charged to the wrong spool (#2768).
+    logger.info(
+        "[SPOOLMAN] Print start: archive %s slot_to_tray=%s (source: %s)",
+        archive_id,
+        slot_to_tray,
+        mapping_source or "none",
+    )
     if layer_usage_json:
         logger.debug("[SPOOLMAN] Layer usage data available for partial tracking")
 
@@ -816,9 +919,23 @@ async def _report_partial_usage(
             current_lookup=current_lookup,
             handled_global_tray_ids=set(),
             archive_id=getattr(tracking, "archive_id", -1),
+            print_used_keys=_print_used_tray_keys(slot_to_tray, getattr(tracking, "tray_now_at_start", None), state),
         )
         return
 
+    # Same recovery the completion path does, for the same reason: a print
+    # dispatched from Studio over the cloud left print start with no mapping to
+    # store, and both paths below feed ``slot_to_tray`` to
+    # ``_resolve_global_tray_id`` (#2768). An aborted print charges the wrong
+    # spool just as readily as a finished one.
+    if not slot_to_tray:
+        slot_to_tray, _partial_mapping_source = _resolve_slot_to_tray_fallback(printer_id, filament_usage)
+        logger.info(
+            "[SPOOLMAN] Partial usage: slot_to_tray=%s (source: %s)",
+            slot_to_tray,
+            _partial_mapping_source,
+        )
+
     # Try to use accurate G-code parsed data
     if layer_usage:
         layer_usage_int = {
@@ -948,6 +1065,7 @@ async def report_usage(printer_id: int, archive_id: int):
         # on read.
         layer_usage_raw = getattr(tracking, "layer_usage", None) or {}
         filament_properties = getattr(tracking, "filament_properties", None) or {}
+        tray_now_at_start = getattr(tracking, "tray_now_at_start", None)
         printer_serial = await _get_printer_serial(printer_id)
 
         # Delete tracking row (we're done with it)
@@ -1000,6 +1118,20 @@ async def report_usage(printer_id: int, archive_id: int):
         # is the print's last valid layer.
         _layer_denom_hint = _total_layers or _current_layer
 
+        # Recover the mapping when print start had nothing to store — the
+        # cloud-dispatched Studio print of #2768. Only the 3MF path consumes
+        # ``slot_to_tray``; the remain-delta path below resolves spools from the
+        # AMS slot directly, so there is nothing to recover for it.
+        mapping_source = "stored" if slot_to_tray else "none"
+        if filament_usage and not slot_to_tray:
+            slot_to_tray, mapping_source = _resolve_slot_to_tray_fallback(printer_id, filament_usage)
+        logger.info(
+            "[SPOOLMAN] Archive %s: slot_to_tray=%s (source: %s)",
+            archive_id,
+            slot_to_tray,
+            mapping_source,
+        )
+
         slot_colors: dict[int, str] = {}
         slot_materials: dict[int, str] = {}
         handled_global_tray_ids: set[int] = set()
@@ -1085,6 +1217,7 @@ async def report_usage(printer_id: int, archive_id: int):
                 current_lookup=current_lookup,
                 handled_global_tray_ids=handled_global_tray_ids,
                 archive_id=archive_id,
+                print_used_keys=_print_used_tray_keys(slot_to_tray, tray_now_at_start, current),
                 slot_colors_out=slot_colors,
                 slot_materials_out=slot_materials,
             )
@@ -1105,6 +1238,49 @@ async def report_usage(printer_id: int, archive_id: int):
         await _apply_spool_types_to_archive(db, archive_id, filament_usage, slot_materials)
 
 
+def _print_used_tray_keys(
+    slot_to_tray: list | None,
+    tray_now_at_start: int | None,
+    state,
+) -> set[tuple[int, int]]:
+    """Which AMS slots this print actually drew from, as far as we can tell.
+
+    Mirrors the guard the internal tracker has carried since #1269. Without
+    it, swapping a spool in a slot the print never touched drops that slot's
+    ``remain%``, and the remain-delta path reads the drop as consumption and
+    charges it to whoever the slot is assigned to. That is a phantom write to
+    an uninvolved spool, and it is likeliest on exactly the prints this
+    fallback serves -- ones with no 3MF, where nothing else limits which slots
+    are considered.
+
+    Three sources, matching the internal tracker's:
+
+    - the print's ``ams_mapping``, stored here as ``slot_to_tray``;
+    - every tray the printer switched to mid-print;
+    - the tray it was drawing from at the start.
+
+    An empty result means no evidence, not "no slots" -- callers must then
+    consider every slot, as before, or a printer that reports none of the
+    three would silently stop being tracked at all.
+
+    Takes the two stored values rather than the tracking row: the caller
+    deletes that row before it gets this far, and everything read off it is
+    read into locals beforehand.
+    """
+    keys: set[tuple[int, int]] = set()
+    for global_tray_id in list(slot_to_tray or []):
+        if isinstance(global_tray_id, int) and global_tray_id >= 0:
+            keys.add(_global_tray_id_to_ams_slot(global_tray_id))
+    for change in getattr(state, "tray_change_log", None) or []:
+        if isinstance(change, (tuple, list)) and change:
+            global_tray_id = change[0]
+            if isinstance(global_tray_id, int) and global_tray_id >= 0:
+                keys.add(_global_tray_id_to_ams_slot(global_tray_id))
+    if isinstance(tray_now_at_start, int) and 0 <= tray_now_at_start <= _MAX_REAL_TRAY_ID:
+        keys.add(_global_tray_id_to_ams_slot(tray_now_at_start))
+    return keys
+
+
 async def _report_remain_delta_for_slots(
     client,
     *,
@@ -1113,6 +1289,7 @@ async def _report_remain_delta_for_slots(
     current_lookup: dict[str, dict],
     handled_global_tray_ids: set[int],
     archive_id: int,
+    print_used_keys: set[tuple[int, int]] | None = None,
     slot_colors_out: dict[int, str] | None = None,
     slot_materials_out: dict[int, str] | None = None,
 ) -> int:
@@ -1125,6 +1302,7 @@ async def _report_remain_delta_for_slots(
     unreliable ``tray_weight`` (which is the failure mode #1119 documented).
     """
     spools_updated = 0
+    not_in_print: list[str] = []
     for slot_key, start in tray_remain_start.items():
         try:
             ams_id_str, tray_id_str = slot_key.split("-", 1)
@@ -1144,9 +1322,24 @@ async def _report_remain_delta_for_slots(
         if global_tray_id in handled_global_tray_ids:
             continue
 
+        # Slots the print never touched (#1269's guard, see _print_used_tray_keys).
+        # Only enforced when there is evidence of which slots it did use.
+        # Collected rather than logged per slot: on a four-AMS farm a
+        # single-colour print leaves fifteen of these, and they are the
+        # expected case, unlike the "consumed but charged nothing" lines below.
+        if print_used_keys and (ams_id, tray_id) not in print_used_keys:
+            not_in_print.append(f"AMS{ams_id}-T{tray_id}")
+            continue
+
         current = current_lookup.get(slot_key)
         if not current:
-            logger.debug("[SPOOLMAN] AMS%d-T%d: no current remain%% at completion, skipping fallback", ams_id, tray_id)
+            # Reported at info, like the internal tracker's equivalent: on a
+            # near-empty spool the AMS reports a negative remain%, which the
+            # snapshot gate rejects, and the slot that was actually printing
+            # disappears from this path entirely (#1820).
+            logger.info(
+                "[SPOOLMAN] AMS%d-T%d: no valid remain%% at completion, nothing charged for this slot", ams_id, tray_id
+            )
             continue
 
         # Spool swap mid-print — tray_uuid changed. We don't know how much
@@ -1161,11 +1354,28 @@ async def _report_remain_delta_for_slots(
 
         delta_pct = start["remain"] - current["remain"]
         if delta_pct <= 0:
+            # A fresh spool reads 100% for the first tens of grams and the AMS
+            # estimate drifts upward on its own, so this covers a real print
+            # that simply left no trace at AMS granularity -- not only a refill.
+            # Said out loud so it can be told apart from having nothing to
+            # charge, which is what "no spools updated" alone looked like.
+            logger.info(
+                "[SPOOLMAN] AMS%d-T%d: remain%% did not fall over the print (%d%% -> %d%%), nothing charged",
+                ams_id,
+                tray_id,
+                start["remain"],
+                current["remain"],
+            )
             continue  # No consumption captured at AMS granularity, or refilled
 
         spool_id = await _resolve_spool_id_via_slot_assignment(printer_id, ams_id, tray_id)
         if spool_id is None:
-            logger.debug("[SPOOLMAN] AMS%d-T%d: no Spoolman slot assignment, skipping fallback", ams_id, tray_id)
+            logger.info(
+                "[SPOOLMAN] AMS%d-T%d: consumed %d%% but has no Spoolman slot assignment, nothing charged",
+                ams_id,
+                tray_id,
+                delta_pct,
+            )
             continue
 
         # Look up the spool's filament reference weight. Use a fresh GET so
@@ -1222,6 +1432,12 @@ async def _report_remain_delta_for_slots(
             ref_weight,
             spool_id,
         )
+    if not_in_print:
+        logger.info(
+            "[SPOOLMAN] Archive %s: slots not part of this print, left alone: %s",
+            archive_id,
+            ", ".join(not_in_print),
+        )
     return spools_updated
 
 

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů