소스 검색

Merge pull request #3000 from maziggy/1.2.5.4

**Bambuddy 1.2.5.4**

**What this is**

A fix-heavy release on top of 1.2.5.3, with one substantial feature running through it: a spool now carries a different filament preset per printer model and a K profile per hotend, and every path that configures an AMS slot honours both. Around that sit scheduled AMS drying, Filament Track Switch support, Dutch as the fourteenth interface language, Home Assistant sensors bound to storage locations, and roughly 90 fixes - the heaviest runs on AMS and K profiles, on archives from prints Bambuddy did not dispatch, and on Spoolman cost attribution. Seven of the changes come from outside contributors. No breaking changes. Three new tables, one new column and six one-shot repair passes 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.4-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**

- A spool carries a filament preset per printer model, and a K profile per hotend - A slicer preset is bound to a printer model: `Bambu PLA Basic @BBL X1C` is not the same preset as `@BBL H2C`. A spool stored exactly one, which was right until you used that spool on a second machine - the AMS slot on the other printer was then configured with a preset it has no profile for. The spool form's PA Profile tab becomes a **Printers** tab holding both halves: a model list on the left, and on the right that model's presets and the K profiles for each of its hotends. K profiles also distinguish High Flow from Standard nozzles, because a printer files each calibration under a nozzle id like `HH00-0.4` or `HS00-0.4` and can hold both for one diameter - a maintainer's H2D carries 102 high-flow entries and 6 standard, and the same filament reads a different K through each. A stored profile is no longer applied when the fitted nozzle disagrees; the picker marks it rather than letting it look configured while quietly doing nothing. Every path that configures a slot respects both: manual assign in either inventory mode, RFID auto-assign, the Spoolman tag link, the re-fire when a slot goes from empty to loaded, the re-apply after a calibration-table refresh, and the re-selection when a Filament Track Switch moves an AMS to the other nozzle.

- Filament Track Switch: the inlet each AMS feeds, and K profiles that follow it - With a switch fitted an AMS is not wired to a nozzle. It is plumbed into one of the swi
tch's two inlets and reaches both hotends through it, so every unit reports its extruder as "not fixed" and `ams_extruder_map` comes back empty. Bambuddy had nothing to fall back on but the AMS unit number, so AMS-A was badged R and AMS-B was badged L purely because their ids are 0 and 1, a third unit got no badge at all, and every one of those labels was wrong. The inlet is now read from the switch, and a print asks which nozzle to feed rather than guessing.

- Scheduled AMS drying (#2703) - A drying run can start now, after a delay, or at a chosen time, and the printer's queue is held while it runs. The delayed dispatch goes through the same preflight the immediate button does, so a run the button would refuse is never silently published by the schedule; the blocking firmware reason is chosen by one shared rule, so a blocked AMS no longer reads two different ways depending on which control you pressed. A completed or cancelled run releases the printer - without that, a nightly off-peak dry with no printing in between worked on night one and silently did not on every night after. Pending and failed runs are shown on the printer card.

- Dutch (nl) is now a supported interface language (#2891, requested and contributed by @Igiegel) - the fourteenth locale, listed as "Nederlands" in the language picker.

- Home Assistant sensors can be bound to a storage location, so a drybox reports its own humidity (#2824) - the sensor readings you already surface on a printer card can now be attached to a shelf, drawer or drybox instead.

- An Avery sheet can start at the first unused position (#2879, requested and contributed by @whitigol in #2918) - label PDFs always began in the top-left slot, so printing two labels onto a part-used 30-slot sheet meant spending the whole sheet or nothing. A **Starting label position** field says which slot to begin at, counted the way the sheet reads.

- A fault's description is in the status response (#2926, proposed and analysed by @sadontsev) - `HMS_ERROR_DESCRIPTIONS` has been in the backend all along and the status response never carried it, so every consumer that wanted to tell a user *why* a print halted kept its own copy of the same 853 codes: this repo's Python table, the frontend modal's, and at least one third-party iOS client whose catalogue exists purely because the server would not say.

- A virtual printer can be told which address to advertise (#2930, reported and diagnosed by @sebimarkgraf) - `VIRTUAL_PRINTER_ADVERTISE_ADDRESS` sets the address written into the MQTT status that slicers read their FTP upload destination from. On Docker bridge networking the virtual printer is reached on the host's LAN IP but binds something like `172.24.0.2`, and that private address was what the slicer got.

- Printer file downloads can be selected in ranges, and print-history videos downloaded (#2850, requested and contributed by @logikal in #2853) - large selections are prepared on the app data volume rather than buffered in server and browser memory, with per-file compression, progress, cancellation and partial results.

- The K value is on the AMS slot itself, not only in the popover (#2532, requested and contributed by @gyrene2083) - checking whether a calibration took across four slots was four hovers, and comparing two of them side by side was not possible at all.

- Bambuddy asks a printer that refuses FTPS what it actually said (#2780, measured by @grolmus) - Python reports `[SSL: WRONG_VERSION_NUMBER]` and the bytes that caused it are gone, consumed by the TLS layer. The client now opens one plain connection straight after and logs the printer's own words, so a refusal like `421 Too many connections` identifies the fault outright.

**Changed**

- The spool form is wider, and colour, weight and cost move to their own **Color & Cost** tab.
- Home Assistant sensors get their own settings tab instead of sharing the Smart Plugs one (#2824), and storage locations sort the way they are named rather than putting "Drybox 10" between "Drybox 1" and "Drybox 2".
- Camera view mode is picked at the camera button, per printer, instead of one global dropdown in Settings.
- A file dropped on a busy or offline printer is queued instead of refused (#2849, reporter @abraha2d).
- An archive that arrives with only a name now says what to do about it (#2843, reporter @gyrene2083).
- Generated thumbnails are lit, so one model no longer looks like the next (#2816, requested by @NaegeliJ, contributed by @sadontsev in #2861).
- The Watchtower we recommend for daily builds is the maintained fork (#2917, reported by @CamelT0E).
- The Windows installer build is split in two so a signing request can wait for a human (SignPath Foundation).

**Fixes**

**AMS slots, K profiles and the Filament Track Switch:**

- A K profile could be saved against the wrong hotend and applied to the wrong one; RFID auto-assign picked one without checking which hotend it was calibrated on; and moving an AMS to the other nozzle re-selected for the wrong one.
- Swapping a spool left the previous spool's preset name on the AMS slot card - the stored preset is fetched over REST while the tray arrives on the socket, and the card trusted the cached row over live telemetry.
- The K value on a slot card went blank after a while and came back only after a backend restart (#2854); a slot could show the other nozzle's K value; a nozzle swapped out could keep showing its own; and a fresh install showed no K values at all until someone opened the Profiles page.
- Reading a printer's calibration table could stall for 20 seconds.
- Configure Slot could bind the default K value for a profile the picker was visibly showing.
- Load and Unload in the AMS slot menu did nothing on a printer with a Filament Track Switch.
- A manual K-profile calibration left a print in your archive - the automatic run was already filtered, the hand-started one was not, in either of its two shapes.
- Automatic flow-dynamics calibration left an archive and two notifications behind.
- An AMS slot could name the wrong white - `#FFFFFF` is Jade White in PLA Basic, Ivory White in PLA Matte and plain White in six other materials (#2875).
- One ASA spool parked in the AMS added 20 minutes to every PLA print (#2886, reported by @FirstRulez).
- Linking a Spoolman spool by tag configured the slot as generic filament.

**Spools, inventory and Spoolman:**

- A spool you assigned to an AMS slot unassigned itself seconds later, and the slot's colour changed with it (#2987, reported by @frethop).
- Spoolman reset your renamed extra fields on every restart (#2983, reported by @ngreatorex).
- Turning Spoolman mode on deleted every built-in slot assignment, and turning it back off did not restore them (#2812).
- The first AMS sync after enabling Spoolman from Settings failed on every slot (#2903, diagnosed by @ojimpo), and the connection status described Bambuddy's memory rather than Spoolman.
- Every Bambu RFID spool was added with the wrong empty-spool weight (#2909, reported and fixed by @ojimpo in #2923); existing rows are corrected on upgrade.
- A clear spool synced to Spoolman as pure black (#2912, reported and contributed by @ojimpo in #2924), and translucent spools showed as an empty circle or solid black in four more places.
- An AMS slot card showed a multi-colour spool as one flat band (#2967, reported by @NeighborGeek), and a wood, silk or gradient roll the AMS added for you was drawn as a flat disc.
- An AMS slot assigned a PLA+ spool became unusable for PLA (#2902, reported by @doncaruana), and a wood-filled spool was named as plain PLA on its slot.
- AMS slots were offered as places to store a spool.
- The print dialog named an AMS slot after the wrong spool.

**Cost and charging:**

- Print cost ignored the linked Spoolman spool's price and always used the default rate (#2591, reported by @khaosdoctor), and a rescan or recalculation quietly replaced a Spoolman-derived cost with a default-rate one.
- A print sent from Bambu Studio charged the wrong Spoolman spool and rewrote the archive to match (#2953, reported by @bitelvl1).
- A print with no 3MF borrowed another model's filament and cost (#2843, reporter @gyrene2083), and one that could not debit a spool said nothing about it (#2812). Such a print can now be given its filament weight by hand (#1820, reported by @ojimpo).

**Archives, uploads and printer connection:**

- A print that could not fetch its own 3MF could be charged another plate's filament (#2957, reported by @doncaruana) - a same-named 3MF from the library or an earlier archive was accepted on filename alone, and Bambu Studio writes the printer-side name from the project title, so every plate of a project arrives under one name. Alongside it: a slow-but-healthy download was cut off at 30 seconds, two heavy FTPS transfers could run against one printer at once, and the cover thumbnail re-downloaded a 3MF another part of Bambuddy had just fetched.
- A print archived without its 3MF never got its timelapse, and on a short print could be given somebody else's (#2957 follow-up, reported by @doncaruana).
- A print started from the printer's own screen swept every FTP path, archived blank, and then blamed a slicer setting (#1820).
- A print that started during an FTPS pause was archived empty forever, even after Bambuddy downloaded the file (#2957).
- Some archived 3MFs lost their G-code when re-imported into the File Manager (#2993) - nothing was lost from the file; the Archives card judged it by what it holds while the library judged it by its filename, so a sliced 3MF stored as `Foo.3mf` came back as a source-only project with no Print button. Both now read the zip, and files already in your library are re-checked once on the next start.
- Every print archived from a Bambu slice had no bed temperature, so preheat guessed one (#2989, reported by @senguendk); existing archives are repaired on upgrade.
- A reprint of a file already on the printer lost its thumbnail (#2780 regression), and a printer that keeps its files on the card was written off as internal-storage-only (#2856, reporter @aishlai).
- A failed upload told you to check the SD card, whatever had actually gone wrong (#2899, reported by @grolmus), and one handshake failure took out three queued jobs and every retry they had (#2898).
- The connection diagnostic mistook a slicer's print for one of Bambuddy's own (#2843 follow-up).
- Archive metadata could describe a plate that was never printed, and the archives API never reported which plate was printed (#2796, contributed by @sgiffhorn).
- One unexplained disconnect could stop a printer reporting slicer print mappings for good.

**Slicing:**

- Everything the internal slicer produced was Bambu green, whatever filament was picked (#2977, reported by @fadudba), and a preset the slicer could not resolve was sliced as PLA at 200 C without saying so.
- The internal slicer picked PETG for a PLA plate and an A1 process for a P1S (#2982, reported by @Igiegel); every slice that didn't name its own process quietly got the slowest one the slicer ships; and an H2D Pro classified every bundled preset as another printer's.
- The slice dialog took settings from the file with "Use the file's built-in settings" switched off (#2942, reported by @zevulos), and slicing could ignore the process preset you picked.

**Camera and timelapse:**

- A camera snapshot occasionally logged an asyncio ERROR with a traceback into the camera code (#2968, reported by @ceasley).
- Deleting a print with no 3MF left its timelapse and its uploaded source on disk (#2968).
- Every ffmpeg failure logged its build banner instead of the error (#2968).

**Queue, projects and batches:**

- A batch order whose queued runs were deleted became a card that could neither be queued nor closed (#2960).
- A queued job switched on printers that could never have printed it (#2876).
- Archived projects crowded out the live ones in every project picker (#2888, reported by @e77).
- Skip Objects went dead for the rest of a print if Bambuddy restarted while it was running.
- The build plate of a powered-down printer can be cleared again (#2864, reported by @bryanmahin).

**Interface:**

- Every page was a blank white screen on iOS 16.0-16.3 (#2971, reported by @zevulos).
- Card and row actions were unreachable on phones and tablets (#2865, reported by @aishlai).
- The full-page G-code preview grew without limit and never drew anything (#2887, reported by @ojimpo).
- Picking a spool near the bottom of the label dialog could scroll the dialog itself out of view, and a fully transparent spool printed a label with no QR code (#2918, found and fixed by @whitigol).
- The File Manager's card menu no longer loses its top entry (#2846).
- Closing the bug-report panel no longer throws the capture away and leaves the logs running (#2847).
- A colour mismatch was reported between two filaments the app itself called "Blue" (#2941).
- A printer card said "Unknown stage (72)" where it now says "Preparing".

**Notifications, sensors and energy:**

- Every notification provider vanished from the list after the inventory toggles were wired up (#2827), and two inventory toggles could never be turned on, so stock alerts have never been able to fire.
- One Home Assistant sensor reporting a long text state could stop every printer sensor from updating.
- The AMS temperature alarm fired hourly on ambient room heat, and silencing it cost the colour band (#2905, reported and contributed by @ojimpo in #2943); it also fired for the whole of a drying cycle (#1802, reporter @apizz).
- Energy tracking stopped as soon as a second plug was linked to a printer (#2859).
- A printer whose failure detection was not working showed a green "Safe" badge (#2952).

**Platform and database:**

- Bambuddy could not start against a PostgreSQL server whose messages are not in English (#2949, reported and diagnosed by @dvb6666).
- Timestamps hours ahead of themselves on PostgreSQL in a non-UTC zone (#2855, reporter @Tolga-Unal).
- A failure reason the backend derived and the same reason a user picked counted as two different reasons (#2974, reported by @ojimpo); existing rows are converted to one vocabulary.
- A print that failed on an `hms[]` fault recorded an unlookupable error code.
- Statistics forgot the name of a printer that was deleted with its history kept (#2873, reported by @rembomy).
- The bundled chamber-preheat table was unreadable to the code that reads it.

---
**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 1 주 전
부모
커밋
736e0b0f62
100개의 변경된 파일11982개의 추가작업 그리고 1108개의 파일을 삭제
  1. 3 1
      CHANGELOG.md
  2. 1 1
      README.md
  3. 0 0
      assert
  4. 43 3
      backend/app/api/routes/_spoolman_helpers.py
  5. 311 8
      backend/app/api/routes/archives.py
  6. 8 34
      backend/app/api/routes/camera.py
  7. 178 35
      backend/app/api/routes/inventory.py
  8. 32 11
      backend/app/api/routes/labels.py
  9. 209 19
      backend/app/api/routes/library.py
  10. 324 0
      backend/app/api/routes/location_ha_sensors.py
  11. 16 0
      backend/app/api/routes/notifications.py
  12. 7 1
      backend/app/api/routes/obico.py
  13. 6 0
      backend/app/api/routes/print_log.py
  14. 89 3
      backend/app/api/routes/print_queue.py
  15. 546 137
      backend/app/api/routes/printers.py
  16. 107 0
      backend/app/api/routes/scheduled_dryings.py
  17. 35 17
      backend/app/api/routes/settings.py
  18. 23 7
      backend/app/api/routes/slicer_presets.py
  19. 104 31
      backend/app/api/routes/spoolman.py
  20. 159 33
      backend/app/api/routes/spoolman_inventory.py
  21. 7 3
      backend/app/api/routes/support.py
  22. 103 0
      backend/app/core/auth.py
  23. 1 1
      backend/app/core/config.py
  24. 831 27
      backend/app/core/database.py
  25. 658 105
      backend/app/main.py
  26. 6 0
      backend/app/models/__init__.py
  27. 2 0
      backend/app/models/location.py
  28. 76 0
      backend/app/models/location_ha_sensor.py
  29. 7 0
      backend/app/models/notification.py
  30. 7 1
      backend/app/models/notification_template.py
  31. 8 1
      backend/app/models/printer_ha_sensor.py
  32. 47 0
      backend/app/models/scheduled_drying.py
  33. 8 0
      backend/app/models/spool.py
  34. 90 0
      backend/app/models/spool_filament_preset.py
  35. 11 1
      backend/app/schemas/archive.py
  36. 140 0
      backend/app/schemas/location_ha_sensor.py
  37. 65 3
      backend/app/schemas/notification.py
  38. 8 0
      backend/app/schemas/notification_template.py
  39. 6 0
      backend/app/schemas/print_queue.py
  40. 63 1
      backend/app/schemas/printer.py
  41. 15 2
      backend/app/schemas/printer_ha_sensor.py
  42. 43 0
      backend/app/schemas/scheduled_drying.py
  43. 40 0
      backend/app/schemas/settings.py
  44. 56 1
      backend/app/schemas/slicer.py
  45. 25 0
      backend/app/schemas/spool.py
  46. 330 132
      backend/app/services/archive.py
  47. 716 54
      backend/app/services/bambu_ftp.py
  48. 507 51
      backend/app/services/bambu_mqtt.py
  49. 62 10
      backend/app/services/camera.py
  50. 35 1
      backend/app/services/design_settings.py
  51. 117 0
      backend/app/services/drying_preflight.py
  52. 104 0
      backend/app/services/energy_plug.py
  53. 11 6
      backend/app/services/external_camera.py
  54. 19 0
      backend/app/services/failure_analysis.py
  55. 131 3
      backend/app/services/filament_deficit.py
  56. 6 0
      backend/app/services/firmware_update.py
  57. 61 28
      backend/app/services/ftp_profiles.py
  58. 47 7
      backend/app/services/ha_sensor_manager.py
  59. 45 0
      backend/app/services/hms_errors.py
  60. 42 0
      backend/app/services/inventory_mode.py
  61. 61 23
      backend/app/services/label_renderer.py
  62. 2 1
      backend/app/services/layer_timelapse.py
  63. 216 0
      backend/app/services/location_ha_sensor_manager.py
  64. 22 0
      backend/app/services/location_service.py
  65. 38 0
      backend/app/services/notification_service.py
  66. 63 16
      backend/app/services/obico_detection.py
  67. 43 6
      backend/app/services/plate_thumbnail.py
  68. 54 7
      backend/app/services/print_batch.py
  69. 6 1
      backend/app/services/print_cost_estimate.py
  70. 676 78
      backend/app/services/print_scheduler.py
  71. 152 11
      backend/app/services/print_storage.py
  72. 80 7
      backend/app/services/printer_diagnostic.py
  73. 155 14
      backend/app/services/printer_manager.py
  74. 728 0
      backend/app/services/printer_media.py
  75. 82 0
      backend/app/services/slice_output_check.py
  76. 63 12
      backend/app/services/slicer_filament_resolver.py
  77. 189 0
      backend/app/services/slot_kprofile.py
  78. 179 0
      backend/app/services/slot_nozzle.py
  79. 85 42
      backend/app/services/spool_assignment_notifications.py
  80. 127 0
      backend/app/services/spool_filament_preset.py
  81. 105 19
      backend/app/services/spool_tag_matcher.py
  82. 140 21
      backend/app/services/spoolman.py
  83. 406 30
      backend/app/services/spoolman_tracking.py
  84. 133 2
      backend/app/services/stl_thumbnail.py
  85. 8 3
      backend/app/services/timelapse_processor.py
  86. 241 21
      backend/app/services/usage_tracker.py
  87. 73 14
      backend/app/services/virtual_printer/mqtt_bridge.py
  88. 88 0
      backend/app/utils/ams_drying.py
  89. 23 1
      backend/app/utils/archive_paths.py
  90. 54 0
      backend/app/utils/color_utils.py
  91. 102 0
      backend/app/utils/ffmpeg_output.py
  92. 237 0
      backend/app/utils/filament_types.py
  93. 76 0
      backend/app/utils/fts_routing.py
  94. 36 0
      backend/app/utils/http.py
  95. 66 0
      backend/app/utils/kprofile_lookup.py
  96. 20 0
      backend/app/utils/natural_sort.py
  97. 82 0
      backend/app/utils/print_jobs.py
  98. 123 0
      backend/app/utils/threemf_tools.py
  99. 55 0
      backend/tests/conftest.py
  100. 136 0
      backend/tests/integration/test_ams_drying_latch_persistence.py

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 3 - 1
CHANGELOG.md


+ 1 - 1
README.md

@@ -293,7 +293,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
 - **Bulk spool addition** — Add multiple identical spools at once (quantity 1–100) with a single form submission. Quick Add mode for stock spools that only need material, color, and weight.
 - Spool catalog, color catalog, PA profile matching, and low-stock alerts
 - **Multi-colour gradients, transparency, and visual effects** — Paste a comma-separated hex list (e.g. from 3dfilamentprofiles.com) to render a spool as a gradient or conic colour wheel; transparency shows through a checkerboard so the alpha you set is the alpha you see; pick a visual effect (sparkle, wood, marble, glow, matte) for the swatch overlay. Same fields are editable on the colour catalog so combos can be reused across spools.
-- **Printable spool labels** — Generate PDF labels for any selection of spools in four pre-built sizes: AMS holder (30×15 mm), box label (62×29 mm), Avery L7160 sheet (A4, 21 per page), and Avery 5160 sheet (US Letter, 30 per page). Each label shows the colour swatch, brand, material, name, the **spool ID** (for at-a-glance identification across many similar spools), and a QR code that deep-links straight back to the spool's row in Bambuddy when scanned with a phone. Pick from the inventory page — search, filter by material, multi-select spools, then print or save to PDF.
+- **Printable spool labels** — Generate PDF labels for any selection of spools in four pre-built sizes: AMS holder (30×15 mm), box label (62×29 mm), Avery L7160 sheet (A4, 21 per page), and Avery 5160 sheet (US Letter, 30 per page). Each label shows the colour swatch, brand, material, name, the **spool ID** (for at-a-glance identification across many similar spools), and a QR code that deep-links straight back to the spool's row in Bambuddy when scanned with a phone. Pick from the inventory page — search, filter by material, multi-select spools, then print or save to PDF. For a partially used Avery sheet, choose the first unused label position; Bambuddy leaves the earlier positions blank and starts later pages from position 1.
 
 ### 🔧 Integrations
 - [Spoolman](https://github.com/Donkie/Spoolman) filament sync with per-filament usage tracking and fill level display

+ 0 - 0
assert


+ 43 - 3
backend/app/api/routes/_spoolman_helpers.py

@@ -28,6 +28,8 @@ class MappedSpoolFields(TypedDict):
     color_name: str | None
     color_name_is_synthesized: bool
     rgba: str | None
+    extra_colors: str | None
+    effect_type: None
     label_weight: int | None
     core_weight: int | None
     core_weight_catalog_id: None
@@ -91,7 +93,10 @@ def assert_safe_spoolman_url(url: str) -> None:
     assert_safe_lan_service_url(url, label="Spoolman URL")
 
 
-_COLOR_HEX_RE = re.compile(r"^[0-9A-Fa-f]{6}$")
+# Six characters, or eight when the filament carries an alpha byte. The write
+# side stores eight only for genuinely translucent spools (#2912); rejecting
+# them here turned every clear spool into neutral grey on read.
+_COLOR_HEX_RE = re.compile(r"^[0-9A-Fa-f]{6}(?:[0-9A-Fa-f]{2})?$")
 _TAG_HEX_RE = re.compile(r"^[0-9A-F]+$")
 
 
@@ -153,6 +158,30 @@ def _extract_extra_str(extra: dict, key: str) -> str:
     return decoded if isinstance(decoded, str) else ""
 
 
+def parse_spoolman_multi_colors(filament: dict) -> list[str]:
+    """Spoolman's ``multi_color_hexes`` as a list of bare 6/8-char hex tokens.
+
+    Spoolman stores the extra stops of a gradient / dual / multi-colour
+    filament here, and writes the field as a comma-separated string in some
+    releases and a list in others -- both shapes are accepted. Tokens keep the
+    case they arrived in and lose any leading ``#``, which is the form
+    ``Spool.extra_colors`` stores and ``parseStops`` on the client expects.
+
+    Shared with the label renderer rather than parsed twice: the two read the
+    same field for the same purpose, and a swatch on a printer card that
+    disagreed with the swatch on the printed label would be worse than either
+    being wrong on its own.
+    """
+    raw = filament.get("multi_color_hexes")
+    if isinstance(raw, str):
+        tokens = raw.split(",")
+    elif isinstance(raw, list):
+        tokens = [str(token) for token in raw]
+    else:
+        return []
+    return [cleaned for token in tokens if (cleaned := token.strip().lstrip("#"))]
+
+
 def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
     """Convert a raw Spoolman spool dict to the InventorySpool-compatible format.
 
@@ -197,10 +226,19 @@ def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
     else:
         subtype = filament_name or None
 
-    # Colour: validate as 6-char hex; fall back to neutral grey for invalid values
+    # Colour: validate as 6- or 8-char hex; fall back to neutral grey for invalid
+    # values. An 8-char value already carries its alpha, so appending the opaque
+    # byte would push it to ten and lose the translucency it was stored to keep.
     raw_color = (filament.get("color_hex") or "").upper().removeprefix("#")
     color_hex: str = raw_color if _COLOR_HEX_RE.match(raw_color) else "808080"
-    rgba: str = color_hex + "FF"
+    rgba: str = color_hex if len(color_hex) == 8 else color_hex + "FF"
+    # Spoolman carries the extra stops but has no concept of a surface effect
+    # -- its only neighbouring field is `multi_color_direction`, which says how
+    # the stops are laid out, not that the filament is silk or glitter. So a
+    # Spoolman spool can render its gradient and never an effect overlay, and
+    # `effect_type` is pinned to None rather than guessed at.
+    extra_stops = parse_spoolman_multi_colors(filament)
+    extra_colors: str | None = ",".join(extra_stops) if extra_stops else None
 
     label_weight: int = _safe_int(filament.get("weight"), 1000)
     real_used_weight: float = _safe_float(spool.get("used_weight"), 0.0)
@@ -265,6 +303,8 @@ def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
         "color_name": color_name,
         "color_name_is_synthesized": color_name_is_synthesized,
         "rgba": rgba,
+        "extra_colors": extra_colors,
+        "effect_type": None,
         "brand": vendor.get("name") or None,
         "label_weight": label_weight,
         "core_weight": _safe_int(

+ 311 - 8
backend/app/api/routes/archives.py

@@ -1,3 +1,4 @@
+import asyncio
 import io
 import json
 import logging
@@ -13,39 +14,53 @@ from fastapi.responses import FileResponse, Response
 from sqlalchemy import and_, case, func, or_, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core import database
 from backend.app.core.auth import (
     RequireCameraStreamTokenIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
+    check_printer_access,
+    current_api_key_if_present,
+    probe_permissions_if_auth_enabled,
     require_ownership_permission,
 )
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
+from backend.app.models.api_key import APIKey
 from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
+from backend.app.models.printer import Printer
 from backend.app.models.spool_usage_history import SpoolUsageHistory
 from backend.app.models.user import User
 from backend.app.schemas.archive import ArchiveResponse, ArchiveSlim, ArchiveStats, ArchiveUpdate
 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.bambu_ftp import ftps_handshake_blocked, list_files_result_async
 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.services.print_storage import (
+    REASON_INTERNAL_HISTORY,
+    REASON_INTERNAL_STORAGE,
+    REASON_NO_EXTERNAL_STORAGE,
+)
+from backend.app.services.printer_media import VIDEO_SUFFIXES, match_ipcam_chunks
 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.http import build_content_disposition, download_error_response, safe_download_filename
 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,
+    names_carry_gcode,
     select_plate_gcode_name,
 )
 
 logger = logging.getLogger(__name__)
 
 router = APIRouter(prefix="/archives", tags=["archives"])
+_PRINTER_MEDIA_LIST_TIMEOUT_SECONDS = 8.0
 
 # Path of the embedded slicer config inside a BambuStudio/OrcaSlicer 3MF.
 _PROJECT_SETTINGS_PATH = "Metadata/project_settings.config"
@@ -324,6 +339,7 @@ def archive_to_response(
         "duplicate_sequence": duplicate_sequence,
         "original_archive_id": original_archive_id,
         "print_name": archive.print_name,
+        "plate_id": archive.plate_id,
         "print_time_seconds": archive.print_time_seconds,
         "filament_used_grams": archive.filament_used_grams,
         "filament_type": archive.filament_type,
@@ -564,7 +580,13 @@ async def no_3mf_warning(
     # 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):
+    #
+    # REASON_INTERNAL_HISTORY comes last on purpose, even though it is the
+    # narrowest: it is the one cause with no remedy at all -- the file was
+    # already on the printer, in an area port 990 does not serve. The two ahead
+    # of it each end in something the operator can do, so when an install has
+    # both, the actionable explanation is the one worth the banner (#1820).
+    for candidate in (REASON_INTERNAL_STORAGE, REASON_NO_EXTERNAL_STORAGE, REASON_INTERNAL_HISTORY):
         if candidate in reasons:
             return {"has_fallback": True, "reason": candidate}
     return {"has_fallback": True, "reason": None}
@@ -1177,6 +1199,30 @@ async def get_archive_stats(
     )
     prints_by_printer = {str(k): v for k, v in printer_result.all()}
 
+    # Names for printers the client can no longer look up. The breakdowns above
+    # key on the id each run recorded, and deleting a printer while keeping its
+    # history leaves that id pointing at nothing, so a chart that used to read
+    # "Ultron" fell back to "Printer 1" (#2873). Every run also stored the name
+    # it printed on, so the last one recorded is what that id was called. The
+    # client still prefers a live printer's current name, which keeps a rename
+    # showing up straight away.
+    last_named_run = (
+        select(func.max(PrintLogEntry.id).label("entry_id"))
+        .where(
+            PrintLogEntry.printer_id.isnot(None),
+            PrintLogEntry.printer_name.isnot(None),
+            *base_conditions,
+        )
+        .group_by(PrintLogEntry.printer_id)
+        .subquery()
+    )
+    name_result = await db.execute(
+        select(PrintLogEntry.printer_id, PrintLogEntry.printer_name).join(
+            last_named_run, PrintLogEntry.id == last_named_run.c.entry_id
+        )
+    )
+    printer_names = {str(printer_id): name for printer_id, name in name_result.all()}
+
     # Time accuracy — compare each completed run's actual duration to the
     # slicer's estimate on the linked archive. Runs without a linked archive
     # (NULL archive_id) or without an estimate are excluded.
@@ -1276,6 +1322,7 @@ async def get_archive_stats(
         total_cost=round(total_cost, 2),
         prints_by_filament_type=prints_by_filament,
         prints_by_printer=prints_by_printer,
+        printer_names=printer_names,
         average_time_accuracy=average_accuracy,
         time_accuracy_by_printer=accuracy_by_printer if accuracy_by_printer else None,
         total_energy_kwh=round(total_energy_kwh, 3),
@@ -1660,7 +1707,7 @@ async def update_archive(
         )
     ),
 ):
-    """Update archive metadata (tags, notes, cost, is_favorite, project_id)."""
+    """Update archive metadata (tags, notes, cost, filament grams, is_favorite, project_id)."""
     from sqlalchemy.orm import selectinload
 
     user, can_modify_all = auth_result
@@ -1679,6 +1726,10 @@ async def update_archive(
         if archive.created_by_id != user.id:
             raise HTTPException(403, "You can only update your own archives")
 
+    # Read before the writes below: the mirror needs to know whether the run's
+    # figure was inherited from this archive or measured on its own (#1820).
+    previous_filament_grams = archive.filament_used_grams
+
     update_payload = update_data.model_dump(exclude_unset=True)
     for field, value in update_payload.items():
         setattr(archive, field, value)
@@ -1693,7 +1744,12 @@ async def update_archive(
     # entry either. Only the latest entry is touched because that's the run
     # the modal is implicitly showing (archive.failure_reason / status are
     # overwritten on each reprint to reflect the latest run's outcome).
-    mirror_fields = {"failure_reason", "status"}
+    # filament_used_grams rides along for the same reason (#1820): the filament
+    # totals on the Projects page and in the Prometheus metrics sum the LOG
+    # ENTRY's grams, not the archive's, so correcting only the archive would fix
+    # the card and leave every aggregate reading the old figure -- or, for a
+    # print that archived without its 3MF, no figure at all.
+    mirror_fields = {"failure_reason", "status", "filament_used_grams"}
     to_mirror = {k: v for k, v in update_payload.items() if k in mirror_fields}
     if to_mirror:
         from backend.app.models.print_log import PrintLogEntry
@@ -1705,6 +1761,18 @@ async def update_archive(
             .limit(1)
         )
         if latest_entry is not None:
+            # ...but never over a figure the run measured for itself. A run's
+            # grams come from the tracked spool delta when there is one, and
+            # only fall back to copying the archive's estimate when there is
+            # not (see _compute_run_filament_grams). Overwriting a measurement
+            # with a typed estimate would lose the better number; the case this
+            # edit exists for -- a print archived with no 3MF -- leaves the run
+            # with nothing at all, so it is covered by the None arm.
+            if "filament_used_grams" in to_mirror and not (
+                latest_entry.filament_used_grams is None or latest_entry.filament_used_grams == previous_filament_grams
+            ):
+                del to_mirror["filament_used_grams"]
+
             for field, value in to_mirror.items():
                 setattr(latest_entry, field, value)
 
@@ -1747,6 +1815,25 @@ async def toggle_favorite(
     return archive
 
 
+async def _spoolman_owns_cost(db: AsyncSession) -> bool:
+    """True when per-spool pricing lives in Spoolman rather than in our tables.
+
+    Both cost recalculations below rebuild a print's cost from
+    ``SpoolUsageHistory``, and fall back to the built-in Filament catalogue or
+    the global default rate when there are no rows for it. In Spoolman mode
+    there are never any rows -- the built-in usage tracker is handed
+    ``spoolman_owns_usage`` at print start and writes none -- so that fallback
+    is not a recalculation, it is a downgrade: it would overwrite the
+    Spoolman-priced figure ``spoolman_tracking`` recorded at completion with a
+    default-rate one, and the per-slot spool resolution it came from is
+    transient and cannot be rebuilt here (#2591).
+    """
+    from backend.app.api.routes.settings import get_setting
+
+    setting = await get_setting(db, "spoolman_enabled")
+    return bool(setting) and setting.lower() == "true"
+
+
 @router.post("/{archive_id}/rescan", response_model=ArchiveResponse)
 async def rescan_archive(
     archive_id: int,
@@ -1817,6 +1904,10 @@ async def rescan_archive(
             if untracked_grams > 0 and default_cost_per_kg > 0:
                 total_cost += (untracked_grams / 1000.0) * default_cost_per_kg
             archive.cost = float(Decimal(str(total_cost)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
+        elif await _spoolman_owns_cost(db) and archive.cost is not None:
+            # Keep what completion priced from the linked spools. A rescan
+            # re-reads the 3MF's metadata; it learns nothing about spools.
+            pass
         else:
             primary_type = archive.filament_type.split(",")[0].strip()
             filament_result = await db.execute(select(Filament).where(Filament.type == primary_type).limit(1))
@@ -1876,7 +1967,10 @@ async def recalculate_all_costs(
         if row[0] is not None and row[1] is not None and row[1] > 0
     }
 
+    spoolman_owns = await _spoolman_owns_cost(db)
+
     updated = 0
+    preserved = 0
     for archive in archives:
         usage = cost_map.get(archive.id)
         if usage is not None:
@@ -1898,6 +1992,11 @@ async def recalculate_all_costs(
             fallback_cost = usage_result.scalar()
             if fallback_cost is not None and fallback_cost > 0:
                 new_cost = round(fallback_cost, 2)
+            elif spoolman_owns and archive.cost is not None:
+                # Priced from the linked Spoolman spools at completion; there is
+                # nothing better to recompute it from here (#2591).
+                new_cost = None
+                preserved += 1
             elif archive.filament_used_grams and archive.filament_type:
                 primary_type = archive.filament_type.split(",")[0].strip()
                 cost_per_kg = filaments.get(primary_type, default_cost_per_kg)
@@ -1909,7 +2008,10 @@ async def recalculate_all_costs(
             updated += 1
 
     await db.commit()
-    return {"message": f"Recalculated costs for {updated} archives", "updated": updated}
+    message = f"Recalculated costs for {updated} archives"
+    if preserved:
+        message += f"; kept {preserved} priced from Spoolman"
+    return {"message": message, "updated": updated, "preserved": preserved}
 
 
 @router.post("/rescan-all")
@@ -2246,6 +2348,203 @@ async def get_thumbnail(
     )
 
 
+@router.get("/{archive_id}/printer-media")
+async def get_archive_printer_media(
+    archive_id: int,
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.ARCHIVES_READ_ALL,
+            Permission.ARCHIVES_READ_OWN,
+        )
+    ),
+    can_list_printer_files: bool = Depends(probe_permissions_if_auth_enabled(Permission.PRINTERS_FILES)),
+    api_key: APIKey | None = Depends(current_api_key_if_present),
+):
+    """Find downloadable timelapse and `/ipcam` files for one print.
+
+    Local attached timelapses are returned without touching the printer.
+    Printer directories are listed only when the caller also has
+    ``printers:files``; otherwise the local result is returned with a warning.
+    Files are downloaded only after the user explicitly selects them in the UI.
+    """
+
+    user, can_read_all = auth_result
+    async with database.async_session() as db:
+        archive = _ensure_archive_visible(await ArchiveService(db).get_archive(archive_id), user, can_read_all)
+        printer = None
+        claimed_timelapse_stems: set[str] = set()
+        if archive.printer_id is not None:
+            printer = (await db.execute(select(Printer).where(Printer.id == archive.printer_id))).scalar_one_or_none()
+            if printer is not None and archive.timelapse_path is None:
+                claimed_timelapse_stems = await _claimed_timelapse_stems(db, archive.printer_id, archive_id)
+
+    local_timelapse = None
+    if archive.timelapse_path:
+        local_path = settings.base_dir / archive.timelapse_path
+        if await asyncio.to_thread(local_path.is_file):
+            local_timelapse = {
+                "name": local_path.name,
+                "size": (await asyncio.to_thread(local_path.stat)).st_size,
+            }
+
+    response = {
+        "archive_id": archive.id,
+        "printer_id": archive.printer_id,
+        "local_timelapse": local_timelapse,
+        "remote_files": [],
+        "warnings": [],
+    }
+    if archive.printer_id is None or archive.started_at is None:
+        return response
+    if not can_list_printer_files:
+        response["warnings"].append("printer_files_forbidden")
+        return response
+
+    if printer is None:
+        response["warnings"].append("printer_missing")
+        return response
+    if api_key is not None:
+        check_printer_access(api_key, printer.id)
+
+    if ftps_handshake_blocked(printer.ip_address):
+        if local_timelapse is None:
+            response["warnings"].append("timelapse_unavailable")
+        response["warnings"].append("ipcam_unavailable")
+        return response
+
+    remote_files: list[dict] = []
+
+    # If no copy was attached to the archive, offer the matching printer-side
+    # timelapse without mutating the archive or deleting anything from the SD.
+    if local_timelapse is None:
+        videos: list[dict] = []
+        any_timelapse_directory_available = False
+        for timelapse_dir in ("/timelapse", "/timelapse/video", "/record", "/recording"):
+            if ftps_handshake_blocked(printer.ip_address):
+                break
+            listing = await list_files_result_async(
+                printer.ip_address,
+                printer.access_code,
+                timelapse_dir,
+                timeout=_PRINTER_MEDIA_LIST_TIMEOUT_SECONDS,
+                printer_model=printer.model,
+            )
+            any_timelapse_directory_available |= listing.available
+            candidates = [
+                file
+                for file in listing.files
+                if not file.get("is_directory") and str(file.get("name") or "").lower().endswith(VIDEO_SUFFIXES)
+            ]
+            if candidates:
+                videos = candidates
+                break
+        if not any_timelapse_directory_available:
+            response["warnings"].append("timelapse_unavailable")
+        if videos:
+            baseline = set(archive.timelapse_baseline or [])
+            eligible = [
+                file
+                for file in videos
+                if str(file.get("name") or "") not in baseline
+                and Path(str(file.get("name") or "")).stem not in claimed_timelapse_stems
+            ]
+            if archive.timelapse_baseline is not None:
+                candidate = eligible[0] if len(eligible) == 1 else None
+            else:
+                candidate, _ = _match_timelapse_by_timestamp(eligible, archive.started_at)
+            if candidate is not None:
+                remote_files.append(
+                    {
+                        "name": candidate.get("name"),
+                        "path": candidate.get("path"),
+                        "size": candidate.get("size") or 0,
+                        "mtime": candidate.get("mtime"),
+                        "kind": "timelapse",
+                    }
+                )
+
+    if ftps_handshake_blocked(printer.ip_address):
+        response["warnings"].append("ipcam_unavailable")
+        response["remote_files"] = remote_files
+        return response
+
+    ipcam_listing = await list_files_result_async(
+        printer.ip_address,
+        printer.access_code,
+        "/ipcam",
+        timeout=_PRINTER_MEDIA_LIST_TIMEOUT_SECONDS,
+        printer_model=printer.model,
+    )
+    if ipcam_listing.available:
+        for file in match_ipcam_chunks(ipcam_listing.files, archive.started_at, archive.completed_at):
+            remote_files.append(
+                {
+                    "name": file.get("name"),
+                    "path": file.get("path") or f"/ipcam/{file.get('name')}",
+                    "size": file.get("size") or 0,
+                    "mtime": file.get("mtime"),
+                    "kind": "ipcam",
+                }
+            )
+    else:
+        response["warnings"].append("ipcam_unavailable")
+
+    response["remote_files"] = remote_files
+    return response
+
+
+@router.post("/{archive_id}/media-download-token")
+async def create_archive_media_download_token(
+    archive_id: int,
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(Permission.ARCHIVES_READ_ALL, Permission.ARCHIVES_READ_OWN)
+    ),
+):
+    """Mint a single-use token bound to an archive's attached timelapse."""
+
+    from backend.app.core.auth import create_slicer_download_token
+
+    user, can_read_all = auth_result
+    async with database.async_session() as db:
+        archive = _ensure_archive_visible(await ArchiveService(db).get_archive(archive_id), user, can_read_all)
+    if not archive.timelapse_path:
+        raise HTTPException(404, "Timelapse not found")
+    timelapse_path = settings.base_dir / archive.timelapse_path
+    if not await asyncio.to_thread(timelapse_path.is_file):
+        raise HTTPException(404, "Timelapse file not found")
+    return {
+        "token": await create_slicer_download_token("archive-timelapse", archive_id),
+        "filename": timelapse_path.name,
+    }
+
+
+@router.get("/{archive_id}/media/dl/{token}/{filename}")
+async def download_archive_media_with_token(
+    archive_id: int,
+    token: str,
+    filename: str,
+):
+    """Consume a resource-bound token and stream an attached timelapse."""
+
+    from backend.app.core.auth import verify_slicer_download_token
+
+    if not await verify_slicer_download_token(token, "archive-timelapse", archive_id):
+        return download_error_response(403, "This download link has already been used or has expired.")
+    async with database.async_session() as db:
+        archive = await ArchiveService(db).get_archive(archive_id)
+    if not archive or not archive.timelapse_path:
+        return download_error_response(404, "This print has no attached timelapse.")
+    timelapse_path = settings.base_dir / archive.timelapse_path
+    if not await asyncio.to_thread(timelapse_path.is_file):
+        return download_error_response(404, "The attached timelapse is no longer on disk.")
+    safe_filename = safe_download_filename(filename, fallback=timelapse_path.name)
+    return FileResponse(
+        path=timelapse_path,
+        filename=safe_filename,
+        headers={"Content-Disposition": build_content_disposition(safe_filename)},
+    )
+
+
 @router.get("/{archive_id}/timelapse")
 async def get_timelapse(
     archive_id: int,
@@ -2523,6 +2822,7 @@ async def scan_timelapse(
             max_retries=ftp_retry_count,
             retry_delay=ftp_retry_delay,
             operation_name=f"Download timelapse {matching_file['name']}",
+            cooloff_ip=printer.ip_address,
         )
     else:
         timelapse_data = await download_file_bytes_async(
@@ -2645,6 +2945,7 @@ async def select_timelapse(
             max_retries=ftp_retry_count,
             retry_delay=ftp_retry_delay,
             operation_name=f"Download timelapse {filename}",
+            cooloff_ip=printer.ip_address,
         )
     else:
         timelapse_data = await download_file_bytes_async(
@@ -3222,8 +3523,10 @@ async def get_archive_capabilities(
         with zipfile.ZipFile(file_path, "r") as zf:
             names = zf.namelist()
 
-            # Check for G-code in the sliced file
-            has_gcode = any(n.startswith("Metadata/") and n.endswith(".gcode") for n in names)
+            # Check for G-code in the sliced file. Shared with the library's
+            # file-type classification so the card's badge and what the File
+            # Manager makes of the same file cannot disagree (#2993).
+            has_gcode = names_carry_gcode(names)
 
             # Check for 3D model in sliced file (fallback if no source)
             if not has_model:

+ 8 - 34
backend/app/api/routes/camera.py

@@ -22,12 +22,12 @@ from backend.app.core.auth import (
     create_camera_stream_token,
 )
 from backend.app.core.database import get_db
-from backend.app.core.logging_filters import redact_url_credentials
 from backend.app.core.permissions import Permission
 from backend.app.models.printer import Printer
 from backend.app.models.user import User
 from backend.app.services.camera import (
     capture_camera_frame,
+    close_tls_proxy,
     create_tls_proxy,
     generate_chamber_image_stream,
     get_camera_port,
@@ -45,6 +45,7 @@ from backend.app.services.camera_fanout import (
     shutdown_broadcaster,
 )
 from backend.app.services.camera_profiles import get_camera_profile
+from backend.app.utils.ffmpeg_output import summarize_ffmpeg_stderr
 
 logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/printers", tags=["camera"])
@@ -407,37 +408,11 @@ async def _terminate_ffmpeg(process: asyncio.subprocess.Process, stream_id: str
         _spawned_ffmpeg_pids.pop(process.pid, None)
 
 
-def _summarize_ffmpeg_stderr(text: str | None) -> str:
-    """Strip ffmpeg's boilerplate banner and keep only actionable lines.
-
-    ffmpeg prints ~20 lines of version/build/configuration/lib headers before
-    any actual error message. Logging the full banner on every retry floods
-    the log (hundreds of lines per failed stream). This filter drops the
-    banner and caps output at the last 10 meaningful lines.
-
-    Credentials are masked here rather than at each ``logger`` call because
-    this is the one funnel every stderr log in this module passes through.
-    ffmpeg echoes the RTSP input URL back in its ``Input #0`` line, which
-    carries the printer access code.
-    """
-    if not text:
-        return ""
-    text = redact_url_credentials(text) or ""
-    banner_prefixes = (
-        "ffmpeg version ",
-        "  built with ",
-        "  configuration:",
-        "  libavutil ",
-        "  libavcodec ",
-        "  libavformat ",
-        "  libavdevice ",
-        "  libavfilter ",
-        "  libswscale ",
-        "  libswresample ",
-        "  libpostproc ",
-    )
-    meaningful = [ln for ln in text.splitlines() if ln.strip() and not ln.startswith(banner_prefixes)]
-    return "\n".join(meaningful[-10:])
+# The banner-stripping summariser moved to backend.app.utils.ffmpeg_output so
+# the seven other places that log ffmpeg stderr could stop truncating it from
+# the front (#2968). Imported under the private name this module has always
+# used: _FfmpegStderrTail and the tests both reach for it by that name.
+_summarize_ffmpeg_stderr = summarize_ffmpeg_stderr
 
 
 class _FfmpegStderrTail:
@@ -851,8 +826,7 @@ async def generate_rtsp_mjpeg_stream(
             await stderr_tail.aclose()
 
         # Shut down the TLS proxy
-        proxy_server.close()
-        await proxy_server.wait_closed()
+        await close_tls_proxy(proxy_server)
 
 
 @router.post("/camera/stream-token")

+ 178 - 35
backend/app/api/routes/inventory.py

@@ -26,6 +26,7 @@ from backend.app.models.settings import Settings
 from backend.app.models.spool import Spool
 from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.models.spool_catalog import SpoolCatalogEntry
+from backend.app.models.spool_filament_preset import SpoolFilamentPreset
 from backend.app.models.spool_k_profile import SpoolKProfile
 from backend.app.models.user import User
 from backend.app.schemas.location import LocationCreate, LocationResponse, LocationUpdate
@@ -34,6 +35,8 @@ from backend.app.schemas.spool import (
     SpoolAssignmentResponse,
     SpoolBulkCreate,
     SpoolCreate,
+    SpoolFilamentPresetBase,
+    SpoolFilamentPresetResponse,
     SpoolKProfileBase,
     SpoolKProfileResponse,
     SpoolResponse,
@@ -53,6 +56,7 @@ from backend.app.services.location_service import (
     rename_location as rename_location_record,
 )
 from backend.app.services.slicer_filament_resolver import resolve_slicer_filament
+from backend.app.services.slot_nozzle import resolve_slot_nozzle
 from backend.app.services.spool_csv import (
     MAX_CSV_IMPORT_BYTES,
     ImportPreview,
@@ -60,13 +64,15 @@ from backend.app.services.spool_csv import (
     parse_and_validate,
     serialize,
 )
+from backend.app.services.spool_filament_preset import resolve_spool_preset
 from backend.app.services.spoolman import SpoolmanClient, get_spoolman_client, init_spoolman_client
 from backend.app.utils.filament_ids import (
     GENERIC_FILAMENT_IDS,
-    MATERIAL_TEMPS,
     filament_id_to_setting_id,
     normalize_slicer_filament,
 )
+from backend.app.utils.filament_types import is_material_name, nozzle_temp_range, printer_filament_type
+from backend.app.utils.natural_sort import natural_sort_key
 from backend.app.utils.tag_normalization import normalize_tag_uid, normalize_tray_uuid
 
 logger = logging.getLogger(__name__)
@@ -114,18 +120,40 @@ async def apply_spool_to_slot_via_mqtt(
 
     state = printer_manager.get_status(printer_id)
 
-    tray_type = spool.material
-    tray_sub_brands = (
-        f"{spool.brand} {spool.material} {spool.subtype}".strip()
-        if spool.brand
-        else f"{spool.material} {spool.subtype}"
-        if spool.subtype
-        else spool.material
-    )
+    # The slot carries the material type; the product line the material column
+    # may actually hold ("PLA+", "HTPLA") stays in tray_sub_brands below, which
+    # is where Bambu puts it too (issue #2902).
+    tray_type = printer_filament_type(spool.material)
+    # Join only the parts that exist. The previous shape interpolated
+    # `spool.subtype` into the branded string without checking it, so a spool
+    # with a brand and no subtype went to the printer as
+    # "Sunlu PLA Matte None" -- the string "None", on the wire (#2987). The
+    # unbranded branch guarded subtype; the branded one did not.
+    tray_sub_brands = " ".join(p for p in (spool.brand, spool.material, spool.subtype) if p) or spool.material
     tray_color = spool.rgba or "FFFFFFFF"
 
     _generic_id_values = _GENERIC_ID_VALUES
-    _known_materials = set(MATERIAL_TEMPS.keys()) | set(GENERIC_FILAMENT_IDS.keys())
+
+    # Which nozzle this slot feeds, and how wide it is. One resolution shared
+    # with every other path that configures a slot (see services.slot_nozzle),
+    # and used twice below -- for the spool's per-model preset override and for
+    # its K profile -- so the two lookups cannot answer for different nozzles.
+    slot_nozzle = resolve_slot_nozzle(state, ams_id, tray_id, printer_manager.get_model(printer_id))
+    nozzle_diameter = slot_nozzle.diameter
+
+    # A cloud or Orca preset is bound to a printer MODEL ("@BBL X1C"), so the
+    # spool's single slicer_filament stops being right the moment the same
+    # spool is used on a second model. resolve_spool_preset returns the
+    # spool's own value unless the user has set an override for this model,
+    # so a spool nobody has configured behaves exactly as it did before.
+    slot_slicer_filament, slot_slicer_filament_name = await resolve_spool_preset(
+        db,
+        spool_id=spool.id,
+        printer_model=printer_manager.get_model(printer_id),
+        nozzle_diameter=nozzle_diameter,
+        fallback_filament=spool.slicer_filament,
+        fallback_name=spool.slicer_filament_name,
+    )
 
     # slicer_filament → (tray_info_idx, setting_id) resolution is shared with
     # the Spoolman-mode route via this helper (#1713). The helper handles
@@ -133,15 +161,20 @@ async def apply_spool_to_slot_via_mqtt(
     # the builtin-name realignment, AND the defensive PFUS/PFCN/material-name
     # sanitization. When it returns an empty tray_info_idx the local
     # current-tray-state + generic-material fallback below rescues the slot.
-    tray_info_idx, setting_id, sub_brand_override = await resolve_slicer_filament(
+    tray_info_idx, setting_id, sub_brand_override, type_override = await resolve_slicer_filament(
         db=db,
         current_user=current_user,
-        slicer_filament=spool.slicer_filament,
-        slicer_filament_name=spool.slicer_filament_name,
+        slicer_filament=slot_slicer_filament,
+        slicer_filament_name=slot_slicer_filament_name,
         material=spool.material,
     )
     if sub_brand_override:
         tray_sub_brands = sub_brand_override
+    # A preset says what its material is; the reduction above only infers it
+    # from whatever wording the spool's material column happens to carry. When
+    # the spool has a preset, its answer wins (issue #2902, @doncaruana).
+    if type_override:
+        tray_type = printer_filament_type(type_override)
 
     if not tray_info_idx:
         if (
@@ -149,16 +182,25 @@ async def apply_spool_to_slot_via_mqtt(
             and current_tray_info_idx not in _generic_id_values
             and not current_tray_info_idx.startswith("PFUS")
             and not current_tray_info_idx.startswith("PFCN")
-            and current_tray_info_idx.upper() not in _known_materials
+            # Shares the resolver's reading of what counts as a material
+            # name, product lines included: a slot written by a Bambuddy from
+            # before #2902 can be holding "PLA+" in this field, and reusing
+            # that would carry the bad id forward instead of replacing it.
+            and not is_material_name(current_tray_info_idx)
             and current_tray_type
             and current_tray_type.upper() == tray_type.upper()
         ):
             tray_info_idx = current_tray_info_idx
         elif tray_type:
-            material = tray_type.upper().strip()
+            # The spool's own wording is tried first and the reduced type only
+            # as a further fallback, so a material that already resolves keeps
+            # resolving to the same id: "PETG HF" has its own generic preset
+            # (GFG96) that reducing it to "PETG" would trade away for GFG99.
+            material = (spool.material or "").upper().strip()
             generic = (
                 GENERIC_FILAMENT_IDS.get(material)
                 or GENERIC_FILAMENT_IDS.get(material.split("-")[0].split(" ")[0])
+                or GENERIC_FILAMENT_IDS.get(tray_type.upper())
                 or ""
             )
             if generic:
@@ -172,24 +214,16 @@ async def apply_spool_to_slot_via_mqtt(
     if tray_info_idx and not setting_id:
         setting_id = filament_id_to_setting_id(tray_info_idx)
 
-    temp_min, temp_max = MATERIAL_TEMPS.get((spool.material or "").upper(), (200, 240))
+    # Same order as the generic-id lookup above: the spool's own wording wins,
+    # the reduced type rescues what it does not cover. Without the second
+    # lookup a PLA+ spool took the 200/240 catch-all instead of PLA's 190/230.
+    temp_min, temp_max = nozzle_temp_range(spool.material, tray_type)
     if spool.nozzle_temp_min is not None:
         temp_min = spool.nozzle_temp_min
     if spool.nozzle_temp_max is not None:
         temp_max = spool.nozzle_temp_max
 
-    nozzle_diameter = "0.4"
-    if state and state.nozzles:
-        nd = state.nozzles[0].nozzle_diameter
-        if nd:
-            nozzle_diameter = nd
-
-    slot_extruder = None
-    if state and state.ams_extruder_map:
-        if ams_id == 255:
-            slot_extruder = 1 - tray_id  # ext-L (tray 0) → extruder 1, ext-R (tray 1) → extruder 0
-        else:
-            slot_extruder = state.ams_extruder_map.get(str(ams_id))
+    slot_extruder = slot_nozzle.extruder
 
     # Prefer exact extruder match, fall back to extruder-agnostic kp for the
     # same nozzle. Hard-skipping on mismatch silently drops valid stored
@@ -199,6 +233,12 @@ async def apply_spool_to_slot_via_mqtt(
     for kp in spool.k_profiles:
         if kp.printer_id != printer_id or kp.nozzle_diameter != nozzle_diameter:
             continue
+        # A profile measured on a high-flow nozzle is not a fact about a
+        # standard one. Rows with no stored flow -- everything saved before
+        # this, and everything from a printer whose table declares none --
+        # still match, see SlotNozzle.flow_matches.
+        if not slot_nozzle.flow_matches(kp.nozzle_type):
+            continue
         if slot_extruder is not None and kp.extruder is not None and kp.extruder == slot_extruder:
             exact_kp = kp
             break
@@ -607,8 +647,10 @@ async def list_locations(
 ):
     """List all storage locations with spool counts."""
     settings = await _load_settings_map(db)
-    result = await db.execute(select(Location).order_by(Location.name))
-    locations = list(result.scalars().all())
+    result = await db.execute(select(Location))
+    # Sorted in Python, not SQL: "Drybox 2" belongs before "Drybox 10", and
+    # ORDER BY name gives the opposite (plain lexicographic) order.
+    locations = sorted(result.scalars().all(), key=lambda loc: natural_sort_key(loc.name))
     counts = await _spool_counts_for_locations(db, locations, settings)
     return [_location_to_response(loc, counts.get(loc.id, 0)) for loc in locations]
 
@@ -744,6 +786,21 @@ async def get_color_name_map(
     Normalized to lowercase 6-char hex without '#'. When multiple catalog entries
     share the same hex (different materials or manufacturers), Bambu Lab wins,
     then default entries, then the first encountered.
+
+    ``by_material`` carries the names that collapsing loses. A hex is not one
+    colour in Bambu's range: #FFFFFF is Jade White in PLA Basic, Ivory White in
+    PLA Matte and plain White in six more, and #000000 is Black except in PLA
+    Matte where it is Charcoal. A caller that knows the material — an AMS slot
+    knows it as ``tray_sub_brands`` — looks up ``"<material>|<hex>"`` there
+    first and falls back to ``colors`` (#2875).
+
+    An entry is included only when it recovers a name the *same manufacturer's*
+    own range lost. Two conditions, both load-bearing: a name equal to the
+    collapsed one is pure weight, and a name from a different manufacturer is
+    not a recovery at all — it would put Prusament's "Pristine White" on every
+    generic white PLA slot in place of Bambu's "Jade White", trading one
+    arbitrary answer for another. What survives is the handful of cases this
+    exists for.
     """
     result = await db.execute(
         select(
@@ -751,24 +808,43 @@ async def get_color_name_map(
             ColorCatalogEntry.color_name,
             ColorCatalogEntry.manufacturer,
             ColorCatalogEntry.is_default,
+            ColorCatalogEntry.material,
         )
     )
-    mapping: dict[str, tuple[str, int]] = {}  # hex → (name, priority); higher priority wins
-    for hex_color, color_name, manufacturer, is_default in result.all():
+    # hex → (name, priority, manufacturer); higher priority wins, first on a tie
+    mapping: dict[str, tuple[str, int, str]] = {}
+    by_material: dict[str, tuple[str, int, str]] = {}  # "material|hex" → same
+    for hex_color, color_name, manufacturer, is_default, material in result.all():
         if not hex_color or not color_name:
             continue
         key = hex_color.lstrip("#").lower()[:6]
         if len(key) != 6:
             continue
+        brand = (manufacturer or "").strip().lower()
         priority = 0
-        if manufacturer and manufacturer.strip().lower() == "bambu lab":
+        if brand == "bambu lab":
             priority += 2
         if is_default:
             priority += 1
         existing = mapping.get(key)
         if existing is None or priority > existing[1]:
-            mapping[key] = (color_name, priority)
-    return {"colors": {k: v[0] for k, v in mapping.items()}}
+            mapping[key] = (color_name, priority, brand)
+        material_key = (material or "").strip().lower()
+        if material_key:
+            # Split on the LAST separator when reading these back: a material is
+            # free text and may itself contain a '|'.
+            qualified = f"{material_key}|{key}"
+            existing = by_material.get(qualified)
+            if existing is None or priority > existing[1]:
+                by_material[qualified] = (color_name, priority, brand)
+
+    colors = {k: v[0] for k, v in mapping.items()}
+    qualified_colors = {}
+    for qualified, (name, _, brand) in by_material.items():
+        flat = mapping.get(qualified.rsplit("|", 1)[1])
+        if flat and flat[0] != name and flat[2] == brand:
+            qualified_colors[qualified] = name
+    return {"colors": colors, "by_material": qualified_colors}
 
 
 @router.post("/colors", response_model=ColorEntryResponse)
@@ -1611,6 +1687,73 @@ async def replace_k_profiles(
     return new_profiles
 
 
+@router.get("/spools/{spool_id}/filament-presets", response_model=list[SpoolFilamentPresetResponse])
+async def list_filament_presets(
+    spool_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+):
+    """List per-printer-model preset overrides for a spool.
+
+    A dedicated endpoint rather than a field on ``SpoolResponse``: the
+    inventory list returns every spool the user owns, and only the spool form
+    and the assign path ever need this list, one spool at a time.
+    """
+    result = await db.execute(select(SpoolFilamentPreset).where(SpoolFilamentPreset.spool_id == spool_id))
+    return list(result.scalars().all())
+
+
+@router.put("/spools/{spool_id}/filament-presets", response_model=list[SpoolFilamentPresetResponse])
+async def replace_filament_presets(
+    spool_id: int,
+    presets: list[SpoolFilamentPresetBase],
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Replace all per-printer-model preset overrides for a spool.
+
+    Replace rather than merge, matching the K-profile endpoint next door: the
+    spool form always holds the complete set, and an empty list is how the
+    user clears every override back to the spool's own preset.
+    """
+    result = await db.execute(select(Spool).where(Spool.id == spool_id))
+    if not result.scalar_one_or_none():
+        raise HTTPException(404, "Spool not found")
+
+    # (model, diameter) is UNIQUE, so a payload that names one twice would
+    # fail on flush with an IntegrityError the client cannot act on. Reject it
+    # by name instead -- and reject it BEFORE deleting the existing rows, so a
+    # bad request cannot wipe overrides it then fails to replace.
+    seen: set[tuple[str, str]] = set()
+    for p in presets:
+        key = (p.printer_model, p.nozzle_diameter)
+        if key in seen:
+            raise HTTPException(
+                422,
+                f"Duplicate override for model {p.printer_model!r} nozzle {p.nozzle_diameter or 'any'!r}",
+            )
+        seen.add(key)
+
+    existing = await db.execute(select(SpoolFilamentPreset).where(SpoolFilamentPreset.spool_id == spool_id))
+    for old in existing.scalars().all():
+        await db.delete(old)
+    # Land the deletes before the inserts: within one transaction SQLAlchemy is
+    # free to order the INSERTs first, which trips the UNIQUE constraint
+    # against rows this call is about to remove.
+    await db.flush()
+
+    new_presets = []
+    for p in presets:
+        row = SpoolFilamentPreset(spool_id=spool_id, **p.model_dump())
+        db.add(row)
+        new_presets.append(row)
+
+    await db.commit()
+    for row in new_presets:
+        await db.refresh(row)
+    return new_presets
+
+
 # ── Spool Assignments ────────────────────────────────────────────────────────
 
 

+ 32 - 11
backend/app/api/routes/labels.py

@@ -5,7 +5,8 @@ Two endpoints, one per inventory backend:
 - ``POST /inventory/labels``  — local-DB spools
 - ``POST /spoolman/labels``   — Spoolman-backed spools
 
-Both accept ``{spool_ids: [int], template: str}`` and return a PDF stream.
+Both accept ``{spool_ids: [int], template: str, starting_position: int}`` and
+return a PDF stream.
 The QR code on each label deep-links to ``/inventory?spool=<id>`` so a phone
 scan jumps straight back into Bambuddy at that spool's row.
 """
@@ -18,17 +19,18 @@ from typing import Literal
 
 from fastapi import APIRouter, Depends, HTTPException, Request
 from fastapi.responses import StreamingResponse
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, model_validator
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.api.routes._spoolman_helpers import parse_spoolman_multi_colors
 from backend.app.api.routes.settings import get_setting
 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.spool import Spool
 from backend.app.models.user import User
-from backend.app.services.label_renderer import LabelData, TemplateName, render_labels
+from backend.app.services.label_renderer import LabelData, TemplateName, get_sheet_capacity, render_labels
 from backend.app.services.spoolman import get_spoolman_client
 from backend.app.utils.http import build_content_disposition
 
@@ -63,6 +65,18 @@ class LabelRequest(BaseModel):
     # Black-and-white thermal printers: drop the colour swatch (prints as a
     # muddy grey block) and widen the text column instead (#1870).
     monochrome: bool = False
+    starting_position: int = Field(default=1, ge=1)
+
+    @model_validator(mode="after")
+    def validate_starting_position(self) -> LabelRequest:
+        capacity = get_sheet_capacity(self.template)
+        if capacity is None:
+            if self.starting_position != 1:
+                raise ValueError("starting_position is only supported for sheet label templates")
+            return self
+        if self.starting_position > capacity:
+            raise ValueError(f"starting_position must be between 1 and {capacity} for template {self.template}")
+        return self
 
 
 def _split_extra_colors(raw: str | None) -> list[str] | None:
@@ -114,12 +128,9 @@ def _spoolman_dict_to_label_data(s: dict, deeplink_base: str) -> LabelData:
     color_hex = filament.get("color_hex")
     rgba = color_hex.lstrip("#") if isinstance(color_hex, str) else None
 
-    multi_colors = filament.get("multi_color_hexes")
-    extra: list[str] | None = None
-    if isinstance(multi_colors, str) and multi_colors.strip():
-        extra = [tok.strip().lstrip("#") for tok in multi_colors.split(",") if tok.strip()]
-    elif isinstance(multi_colors, list):
-        extra = [str(t).strip().lstrip("#") for t in multi_colors if str(t).strip()]
+    # Shared with `_map_spoolman_spool`, so the swatch printed on a label and
+    # the swatch drawn on an AMS slot card cannot read the same field two ways.
+    extra: list[str] | None = parse_spoolman_multi_colors(filament) or None
 
     return LabelData(
         spool_id=int(s.get("id", 0)),
@@ -173,7 +184,12 @@ async def render_local_inventory_labels(
     deeplink_base = await _resolve_deeplink_base(request, db)
     data_list = [_spool_to_label_data(s, deeplink_base) for s in ordered]
 
-    pdf = render_labels(body.template, data_list, monochrome=body.monochrome)
+    pdf = render_labels(
+        body.template,
+        data_list,
+        monochrome=body.monochrome,
+        starting_position=body.starting_position,
+    )
     filename = f"bambuddy-labels-{body.template}.pdf"
     return _stream_pdf(pdf, filename)
 
@@ -217,6 +233,11 @@ async def render_spoolman_labels(
     deeplink_base = await _resolve_deeplink_base(request, db)
     data_list = [_spoolman_dict_to_label_data(by_id[sid], deeplink_base) for sid in body.spool_ids]
 
-    pdf = render_labels(body.template, data_list, monochrome=body.monochrome)
+    pdf = render_labels(
+        body.template,
+        data_list,
+        monochrome=body.monochrome,
+        starting_position=body.starting_position,
+    )
     filename = f"bambuddy-labels-spoolman-{body.template}.pdf"
     return _stream_pdf(pdf, filename)

+ 209 - 19
backend/app/api/routes/library.py

@@ -65,6 +65,7 @@ from backend.app.schemas.library import (
 from backend.app.schemas.slicer import SliceRequest, SliceResponse
 from backend.app.services.archive import ThreeMFParser
 from backend.app.services.design_settings import (
+    DesignOverride,
     apply_design_overrides,
     extract_design_process_overrides,
     overrides_from_config,
@@ -72,7 +73,12 @@ from backend.app.services.design_settings import (
 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.slice_output_check import (
+    missing_start_gcode_message,
+    start_gcode_is_missing,
+    unresolved_filament_message,
+    unresolved_filament_slots,
+)
 from backend.app.services.stl_thumbnail import MIN_USABLE_STL_BYTES, generate_stl_thumbnail
 from backend.app.utils.filename import (
     MAX_FILENAME_BYTES,
@@ -82,6 +88,7 @@ from backend.app.utils.filename import (
 )
 from backend.app.utils.safe_path import PathTraversalError, assert_under, safe_join_under
 from backend.app.utils.threemf_tools import (
+    carries_gcode,
     default_plate_gcode_name,
     expand_to_project_slots,
     extract_embedded_presets_from_3mf,
@@ -140,7 +147,7 @@ def get_library_files_dir() -> Path:
     return files_dir
 
 
-def classify_file_type(filename: str) -> str:
+def classify_file_type(filename: str, file_path: Path | str | None = None) -> str:
     """Return the canonical ``LibraryFile.file_type`` for *filename*.
 
     Compound extensions are preserved — a `.gcode.3mf` file (a sliced
@@ -152,12 +159,23 @@ def classify_file_type(filename: str) -> str:
     downstream gates (gcode download, file-type filter, thumbnail
     extraction) only need to handle one canonical name per file family.
     Files with no extension classify as ``unknown``.
+
+    Pass ``file_path`` and a ``.3mf`` is judged on what the zip actually holds
+    rather than on its name (#2993). The name is not evidence: a plate exported
+    from Studio or a print dispatched through the cloud reaches the archive as
+    ``Foo.3mf``, G-code and all, and downloading that and re-importing it used
+    to land a fully printable file in the library as a source-only project. The
+    file is not opened when the name already settles it, so the common case
+    still costs nothing.
     """
     lower = filename.lower()
     if lower.endswith(".gcode.3mf"):
         return "gcode.3mf"
     ext = os.path.splitext(lower)[1]
-    return ext[1:] if ext else "unknown"
+    file_type = ext[1:] if ext else "unknown"
+    if file_type == "3mf" and file_path is not None and carries_gcode(file_path):
+        return "gcode.3mf"
+    return file_type
 
 
 def get_library_thumbnails_dir() -> Path:
@@ -652,7 +670,7 @@ async def save_3mf_bytes_to_library(
         is_external=is_external,
         filename=filename,
         file_path=_stored_file_path(file_path, is_external),
-        file_type=classify_file_type(filename),
+        file_type=classify_file_type(filename, file_path),
         file_size=len(file_bytes),
         file_hash=file_hash,
         thumbnail_path=to_relative_path(thumbnail_path) if thumbnail_path else None,
@@ -1883,7 +1901,10 @@ async def scan_external_folder(
             except OSError:
                 continue
 
-            file_type = classify_file_type(filename)
+            # The zip is opened for the thumbnail immediately below either way,
+            # so judging a `.3mf` on its contents rather than its name (#2993)
+            # costs this scan nothing.
+            file_type = classify_file_type(filename, filepath)
 
             # Extract thumbnail for 3mf files (including .gcode.3mf sliced
             # outputs — those are 3MF zips on disk and carry the same
@@ -2228,12 +2249,12 @@ async def upload_file(
             validate_print_filename(filename)
         except InvalidFilenameError as e:
             raise HTTPException(status_code=400, detail=str(e)) from e
-        ext = os.path.splitext(filename)[1].lower()
-        # `file_type` is compound-aware (`gcode.3mf` for sliced outputs).
-        # `ext` stays the trailing extension because the on-disk filename
-        # uses it directly and the 3MF-parse branch below still gates on
+        # `ext` stays the trailing extension because the on-disk filename uses
+        # it directly and the 3MF-parse branch below still gates on
         # `ext == ".3mf"`, which is correct for both `.3mf` and `.gcode.3mf`.
-        file_type = classify_file_type(filename)
+        # `file_type` is compound-aware and is decided further down, once the
+        # bytes are on disk to be read.
+        ext = os.path.splitext(filename)[1].lower()
 
         # Verify folder exists if specified
         target_folder = None
@@ -2261,6 +2282,10 @@ async def upload_file(
         with open(file_path, "wb") as f:
             f.write(content)
 
+        # Now that the bytes are on disk the zip can settle what the name only
+        # guessed at: a sliced 3MF uploaded as `Foo.3mf` is a sliced 3MF (#2993).
+        file_type = classify_file_type(filename, file_path)
+
         # Calculate hash
         file_hash = calculate_file_hash(file_path)
 
@@ -2527,7 +2552,6 @@ async def extract_zip_file(
                     # Extract file
                     filename = os.path.basename(zip_path)
                     ext = os.path.splitext(filename)[1].lower()
-                    file_type = classify_file_type(filename)
 
                     # Generate unique filename for storage
                     unique_filename = f"{uuid.uuid4().hex}{ext}"
@@ -2540,6 +2564,11 @@ async def extract_zip_file(
                     with open(file_path, "wb") as f:
                         f.write(file_content)
 
+                    # Classified once the bytes are on disk so a sliced 3MF
+                    # named `Foo.3mf` inside the zip is recognised as sliced
+                    # (#2993) rather than trusted to say so in its name.
+                    file_type = classify_file_type(filename, file_path)
+
                     # Calculate hash
                     file_hash = calculate_file_hash(file_path)
 
@@ -3616,6 +3645,101 @@ def _patch_process_bed_type(process_json: str, bed_type: str) -> str:
     return json.dumps(profile)
 
 
+def _source_plate_colours(model_bytes: bytes) -> list[str]:
+    """Per-slot colours the source 3MF was designed with, or ``[]``.
+
+    Read from ``project_settings.config`` rather than ``slice_info.config``:
+    the latter records the colour the file was *last sliced* with, which for a
+    source that never carried one is the slicer's own #00AE42 default — the
+    exact value #2977 is about, so using it as a fallback would be circular.
+    STL and mesh-only 3MF sources have no project settings and yield ``[]``.
+    """
+    from io import BytesIO
+
+    try:
+        with zipfile.ZipFile(BytesIO(model_bytes), "r") as zf:
+            return [str(f.get("color") or "") for f in extract_project_filaments_from_3mf(zf)]
+    except (zipfile.BadZipFile, OSError, ValueError):
+        return []
+
+
+def _preset_default_colour(profile: dict) -> str:
+    """A filament preset's own ``default_filament_colour``, or ``""``.
+
+    OrcaSlicer's third-party vendor profiles carry this; Bambu Studio's
+    bundled BBL filament profiles carry it nowhere (checked across the whole
+    shipped `resources/profiles/BBL/filament/` tree — zero occurrences), which
+    is why it can only ever be one link in the chain and never the whole fix.
+
+    It is read here and rewritten as ``filament_colour`` because the CLI does
+    not read it itself. Measured against a 02.08.02.61 sidecar: a profile
+    carrying only ``default_filament_colour: ["#FF00FF"]`` still slices to
+    ``filament_colour: ["#00AE42"]``. Bambu Studio consumes the default in the
+    GUI when a project is created, not in ``--load-filaments``.
+    """
+    raw = profile.get("default_filament_colour")
+    if isinstance(raw, list):
+        raw = raw[0] if raw else None
+    return raw.strip() if isinstance(raw, str) else ""
+
+
+def _patch_filament_colours(
+    filament_jsons: list[str],
+    requested: list[str],
+    model_bytes: bytes,
+) -> list[str]:
+    """Write ``filament_colour`` onto each resolved filament profile (#2977).
+
+    Neither slicer stores a colour on a filament *preset* — it is a per-project
+    property their GUIs set from the plate — so a CLI slice with no colour
+    supplied records Bambu Studio's compiled-in default for every slot. That
+    default is `#00AE42`, which is why every internal-slicer output was green
+    regardless of the filament picked, and why the print dialog's AMS mapping
+    reported a colour mismatch against whatever was actually loaded.
+
+    Per slot, first non-empty of:
+
+    1. the caller's explicit colour (the SliceModal's per-slot swatch),
+    2. the preset's own ``default_filament_colour``,
+    3. the colour the source 3MF's plate was designed with.
+
+    All three empty means the slot is left untouched rather than being given a
+    guess: the slicer's default is then still wrong, but it is at least the
+    same wrong value the file would have had before this function existed.
+
+    Returns a new list; a profile that isn't parseable JSON is passed through
+    unchanged, on the same reasoning as ``_patch_process_bed_type`` — a colour
+    is not worth failing a slice that would otherwise succeed.
+    """
+    source_colours = _source_plate_colours(model_bytes) if filament_jsons else []
+    patched: list[str] = []
+    for i, raw in enumerate(filament_jsons):
+        try:
+            profile = json.loads(raw)
+        except json.JSONDecodeError:
+            logger.warning("Filament colour skipped for slot %d: profile is not valid JSON", i + 1)
+            patched.append(raw)
+            continue
+        if not isinstance(profile, dict):
+            patched.append(raw)
+            continue
+        colour = (
+            (requested[i].strip() if i < len(requested) and requested[i] else "")
+            or _preset_default_colour(profile)
+            or (source_colours[i].strip() if i < len(source_colours) and source_colours[i] else "")
+        )
+        if not colour:
+            patched.append(raw)
+            continue
+        # One-element array: the same shape the CLI uses for every other
+        # per-filament field (`filament_type`, `filament_vendor`), and the
+        # shape a `--load-filaments` profile is parsed as. A bare string is
+        # accepted by the JSON parser but not by the config deserialiser.
+        profile["filament_colour"] = [colour]
+        patched.append(json.dumps(profile))
+    return patched
+
+
 # Support-related keys we lift from the source 3MF's project_settings.config
 # into the picked process preset before `--load-settings` sees it (#1881).
 # BambuStudio's shipped process presets ("0.20mm Standard @BBL H2D" etc.)
@@ -3632,7 +3756,31 @@ _SOURCE_PROCESS_SUPPORT_KEYS_TO_PRESERVE = (
 )
 
 
-def _patch_process_support_settings(process_json: str, source_3mf_bytes: bytes) -> str:
+def _declined_source_keys(offered: list[DesignOverride], requested: list[str] | None) -> set[str]:
+    """Settings the file offered and the caller left unticked (#2942).
+
+    The slice dialog lists what the designer changed and applies only the keys
+    that are switched on, so the answer to "which of these does this slice
+    want" is already in the request. This reads the other half of it — the
+    ones that were on offer and turned down — which the support carry-over
+    below must not put back.
+
+    ``requested`` of ``None`` is a caller that predates the per-key choice and
+    so cannot have declined anything; an empty list is one that was shown the
+    file's settings and took none. Collapsing those two into "nothing
+    selected" is what made an empty panel indistinguishable from an old
+    client, and only one of them means the user said no.
+    """
+    if requested is None:
+        return set()
+    return {override.key for override in offered} - set(requested)
+
+
+def _patch_process_support_settings(
+    process_json: str,
+    source_3mf_bytes: bytes,
+    declined: set[str] | frozenset[str] = frozenset(),
+) -> str:
     """Overlay the source 3MF's support configuration onto the process JSON.
 
     The carry is deliberately one-way: a source can switch supports *on*,
@@ -3645,6 +3793,13 @@ def _patch_process_support_settings(process_json: str, source_3mf_bytes: bytes)
     with supports *on* is by definition a deliberate user preset, since
     Bambu's shipped ones all ship them off.
 
+    ``declined`` names keys the caller offered the user as the file's own
+    (#2622) and that the user left unticked, which this carry must then not
+    reinstate behind their back (#2942). It is empty for a source that offers
+    nothing — an OrcaSlicer export carries no ``different_settings_to_system``,
+    so there is nothing to tick and #1881's blanket carry still applies — and
+    for a client that predates the per-key ticks.
+
     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
@@ -3672,7 +3827,11 @@ def _patch_process_support_settings(process_json: str, source_3mf_bytes: bytes)
     if not isinstance(process_cfg, dict):
         return process_json
 
-    carried = {key: src_cfg[key] for key in _SOURCE_PROCESS_SUPPORT_KEYS_TO_PRESERVE if key in src_cfg}
+    carried = {
+        key: src_cfg[key] for key in _SOURCE_PROCESS_SUPPORT_KEYS_TO_PRESERVE if key in src_cfg and key not in declined
+    }
+    if not carried:
+        return process_json
     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,
@@ -3807,6 +3966,11 @@ async def _run_slicer_with_fallback(
         assert ref is not None, "schema validator guarantees filament list is non-None"
         filament_jsons.append(await resolve_preset_ref(db, user, ref, "filament"))
 
+    # Give every slot a colour before anything else touches the list, so the
+    # unused-slot substitution below propagates a complete profile rather than
+    # one that still has to be patched afterwards (#2977).
+    filament_jsons = _patch_filament_colours(filament_jsons, request.filament_colours, model_bytes)
+
     # Bed-type override (#1337): patch curr_bed_type onto the resolved
     # process JSON so the slicer's StaticPrintConfig pass picks up the
     # user's pick instead of whatever the process preset defaults to.
@@ -3860,18 +4024,29 @@ async def _run_slicer_with_fallback(
         # didn't touch) still drive the slice.
         primary_bytes = _sanitize_project_settings_sentinels(primary_bytes)
 
+        # #2622: the process settings the file's designer moved off the stock
+        # preset. Read once — the support patch below needs to know which of
+        # them the user was shown, and the carry after it needs their values.
+        design_offered = extract_design_process_overrides(primary_bytes)
+
+        declined_from_file = _declined_source_keys(design_offered, request.design_overrides)
+
         # #1881: preserve the source 3MF's support configuration on top of
         # the picked process preset. Bambu's shipped process presets set
         # `enable_support: 0` by default (supports are a per-print, not
         # per-quality, decision); `--load-settings` is authoritative so
         # without patching, the source's `enable_support: 1` + support-slot
         # assignments get discarded and the slice comes out single-material
-        # with a PVA slot loaded but never used.
-        presets["process"] = _patch_process_support_settings(presets["process"], primary_bytes)
+        # with a PVA slot loaded but never used. Bounded by the ticks: this
+        # runs for a source that offers no per-key choice at all, and for the
+        # keys of one that does but whose ticks the user left on.
+        presets["process"] = _patch_process_support_settings(
+            presets["process"], primary_bytes, declined=declined_from_file
+        )
 
-        # #2622: carry the designer's own process tweaks onto the picked preset.
-        # BambuStudio records exactly which keys deviate from the system preset
-        # in `different_settings_to_system`, so a MakerWorld author's 5 walls /
+        # Carry the designer's tweaks onto the picked preset. BambuStudio
+        # records exactly which keys deviate from the system preset in
+        # `different_settings_to_system`, so a MakerWorld author's 5 walls /
         # 100% infill / 0.1mm first layer survive a re-slice for another printer
         # instead of being flattened by --load-settings. Opt-in per key: only the
         # keys the caller names are applied, and only if the source really lists
@@ -3880,7 +4055,7 @@ async def _run_slicer_with_fallback(
         if request.design_overrides:
             presets["process"] = apply_design_overrides(
                 presets["process"],
-                extract_design_process_overrides(primary_bytes),
+                design_offered,
                 request.design_overrides,
             )
 
@@ -4231,6 +4406,21 @@ async def _run_slicer_with_fallback(
         )
         raise HTTPException(status_code=502, detail=missing_start_gcode_message(request.printer_preset.id))
 
+    # Found while investigating #2977: a filament preset the sidecar's bundle
+    # cannot resolve is not an error there — the CLI inherits nothing and
+    # slices with its own defaults, so a PETG pick comes back as PLA at 200 C.
+    # Warned rather than refused: the file prints, and the user may well have
+    # meant to slice with a profile their sidecar image predates. Skipped on
+    # the embedded-settings path, which sends no filament profiles for the
+    # bundle to resolve in the first place.
+    if not used_embedded_settings:
+        unresolved = unresolved_filament_slots(result.content, export_3mf=bool(request.export_3mf))
+        if unresolved:
+            logger.warning(
+                "%s",
+                unresolved_filament_message(unresolved, [ref.id for ref in request.filament_presets]),
+            )
+
     return result, used_embedded_settings
 
 

+ 324 - 0
backend/app/api/routes/location_ha_sensors.py

@@ -0,0 +1,324 @@
+"""API routes for Home Assistant sensors bound to a storage location (#2824)."""
+
+import logging
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import select
+from sqlalchemy.exc import IntegrityError
+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.location import Location
+from backend.app.models.location_ha_sensor import LocationHASensor
+from backend.app.models.user import User
+from backend.app.schemas.location_ha_sensor import (
+    HADisplayEntity,
+    LocationHASensorCreate,
+    LocationHASensorReading,
+    LocationHASensorResponse,
+    LocationHASensorUpdate,
+)
+from backend.app.services.homeassistant import homeassistant_service
+from backend.app.services.location_ha_sensor_manager import location_ha_sensor_manager
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/location-ha-sensors", tags=["location-ha-sensors"])
+
+# Reuse the smart-plug permissions, same as ha_sensors.py: both surfaces are
+# "the Home Assistant integration", just scoped to a location instead of a
+# printer. INVENTORY_* would put HA entity bindings behind
+# can_manage_inventory, which defaults to on for API keys (see auth.py) —
+# an inventory-scoped key (e.g. a SpoolBuddy kiosk) would then be able to
+# create, edit and delete HA sensor bindings, a capability the printer
+# sibling deliberately keeps admin-only by leaving SMART_PLUGS_CREATE/
+# UPDATE/DELETE off the API-key allowlist entirely.
+_READ = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ)
+_CREATE = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CREATE)
+_UPDATE = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_UPDATE)
+_DELETE = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_DELETE)
+
+
+# Mirrors categoryFor() in LocationHASensorModal.tsx, which also gates that
+# dialog's entity picker. A device class outside these three has no category
+# and is not subject to the one-per-location rule below.
+#
+# "moisture" is deliberately not mapped to humidity: it is Home Assistant's
+# binary wet/dry class, so a leak detector would otherwise block a real
+# hygrometer on the same location, and it could not carry the category's
+# thresholds anyway — the schema rejects alert_above/alert_below for
+# kind="binary".
+_CATEGORY_BY_DEVICE_CLASS = {
+    "temperature": "temperature",
+    "humidity": "humidity",
+    "battery": "battery",
+}
+
+
+def _category_for(device_class: str | None) -> str | None:
+    return _CATEGORY_BY_DEVICE_CLASS.get(device_class) if device_class else None
+
+
+async def _reject_duplicate_category(
+    db: AsyncSession,
+    location_id: int,
+    device_class: str | None,
+    exclude_sensor_id: int | None = None,
+) -> None:
+    """One sensor per category per location, enforced here and not only in the UI.
+
+    The inventory column and the card footer both pick their reading with a
+    single ``find`` over the location's sensors, so a second temperature
+    sensor does not show up alongside the first — it silently shadows it
+    depending on row order. The modal already prompts to replace rather than
+    add, so this closes the same rule for direct API callers instead of
+    leaving the guarantee resting on the client.
+    """
+    category = _category_for(device_class)
+    if category is None:
+        return
+
+    query = select(LocationHASensor).where(LocationHASensor.location_id == location_id)
+    if exclude_sensor_id is not None:
+        query = query.where(LocationHASensor.id != exclude_sensor_id)
+
+    result = await db.execute(query)
+    for other in result.scalars().all():
+        if _category_for(other.device_class) == category:
+            raise HTTPException(
+                400,
+                f"This location already has a {category} sensor ({other.entity_id}). "
+                "Edit that sensor to point at a different entity instead.",
+            )
+
+
+async def _refresh_quietly(sensor: LocationHASensor, 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 location_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[LocationHASensorResponse])
+async def list_location_ha_sensors(
+    location_id: int | None = None,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = _READ,
+):
+    """List configured sensors, grouped by location and in display order."""
+    query = select(LocationHASensor)
+    if location_id is not None:
+        query = query.where(LocationHASensor.location_id == location_id)
+    result = await db.execute(query.order_by(LocationHASensor.location_id, LocationHASensor.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 storage location."""
+    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-location/{location_id}/readings", response_model=list[LocationHASensorReading])
+async def get_location_sensor_readings(
+    location_id: int,
+    show_on_card: bool = True,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = _READ,
+):
+    """Live state of a location's card-visible sensors.
+
+    Served from the poller's cache, so a page full of filament 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.
+    """
+    conditions = [LocationHASensor.location_id == location_id]
+    if show_on_card:
+        conditions.append(LocationHASensor.show_on_card.is_(True))
+
+    result = await db.execute(
+        select(LocationHASensor).where(*conditions).order_by(LocationHASensor.sort_order, LocationHASensor.id)
+    )
+
+    readings = []
+    for sensor in result.scalars().all():
+        cached = location_ha_sensor_manager.get_reading(sensor.id)
+        readings.append(
+            LocationHASensorReading(
+                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,
+                reachable=cached.reachable if cached else False,
+                alert_state=sensor.alert_state,
+                alert_above=sensor.alert_above,
+                alert_below=sensor.alert_below,
+                last_changed=sensor.last_changed,
+                show_on_card=sensor.show_on_card,
+            )
+        )
+    return readings
+
+
+@router.post("/", response_model=LocationHASensorResponse)
+async def create_location_ha_sensor(
+    data: LocationHASensorCreate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = _CREATE,
+):
+    """Bind a Home Assistant entity to a storage location."""
+    location = await db.get(Location, data.location_id)
+    if not location:
+        raise HTTPException(404, "Location not found")
+
+    existing = await db.execute(
+        select(LocationHASensor).where(
+            LocationHASensor.location_id == data.location_id,
+            LocationHASensor.entity_id == data.entity_id,
+        )
+    )
+    if existing.scalar_one_or_none():
+        raise HTTPException(400, f"{data.entity_id} is already bound to this location")
+
+    await _reject_duplicate_category(db, data.location_id, data.device_class)
+
+    sensor = LocationHASensor(**data.model_dump())
+    db.add(sensor)
+    try:
+        await db.commit()
+    except IntegrityError:
+        # The duplicate check above is read-then-insert, so a concurrent
+        # create for the same (location, entity) can get past it — the unique
+        # index is the backstop, and its loser should read like the pre-check.
+        await db.rollback()
+        raise HTTPException(400, f"{data.entity_id} is already bound to this location") from None
+    await db.refresh(sensor)
+    logger.info("Bound HA entity %s to location %s as '%s'", sensor.entity_id, sensor.location_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=LocationHASensorResponse)
+async def get_location_ha_sensor(
+    sensor_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = _READ,
+):
+    sensor = await db.get(LocationHASensor, sensor_id)
+    if not sensor:
+        raise HTTPException(404, "Sensor not found")
+    return sensor
+
+
+@router.patch("/{sensor_id}", response_model=LocationHASensorResponse)
+async def update_location_ha_sensor(
+    sensor_id: int,
+    data: LocationHASensorUpdate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = _UPDATE,
+):
+    sensor = await db.get(LocationHASensor, 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 show_on_card 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 LocationHASensorCreate.model_fields}
+    merged.update(updates)
+    try:
+        LocationHASensorCreate(**merged)
+    except ValueError as e:
+        raise HTTPException(422, str(e)) from e
+
+    # Same uniqueness rule as create: repointing a sensor at an entity the
+    # location already has would leave two rows fighting over one reading.
+    new_entity = updates.get("entity_id")
+    if new_entity and new_entity != sensor.entity_id:
+        clash = await db.execute(
+            select(LocationHASensor).where(
+                LocationHASensor.location_id == sensor.location_id,
+                LocationHASensor.entity_id == new_entity,
+                LocationHASensor.id != sensor.id,
+            )
+        )
+        if clash.scalar_one_or_none():
+            raise HTTPException(400, f"{new_entity} is already bound to this location")
+
+    # Same one-per-category rule as create, against the merged row and
+    # excluding this sensor — repointing a sensor within its own category
+    # (the modal's replace flow) stays allowed.
+    if "device_class" in updates:
+        await _reject_duplicate_category(db, sensor.location_id, merged["device_class"], exclude_sensor_id=sensor.id)
+
+    for field, value in updates.items():
+        setattr(sensor, field, value)
+    # Read before commit: after a rollback the instance is expired, and
+    # touching its attributes from async code raises MissingGreenlet.
+    entity_id = sensor.entity_id
+    try:
+        await db.commit()
+    except IntegrityError:
+        # Same backstop as create: the clash check above races a concurrent
+        # write, and the unique index decides who loses.
+        await db.rollback()
+        raise HTTPException(400, f"{entity_id} is already bound to this location") from None
+    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_location_ha_sensor(
+    sensor_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = _DELETE,
+):
+    sensor = await db.get(LocationHASensor, sensor_id)
+    if not sensor:
+        raise HTTPException(404, "Sensor not found")
+
+    name = sensor.name
+    await db.delete(sensor)
+    await db.commit()
+    location_ha_sensor_manager.forget(sensor_id)
+    logger.info("Removed location HA sensor '%s'", name)
+    return {"message": f"Sensor '{name}' removed"}

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

@@ -51,6 +51,11 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
         "on_ai_failure_detection": provider.on_ai_failure_detection,
         "on_filament_low": provider.on_filament_low,
         "on_maintenance_due": provider.on_maintenance_due,
+        # Home Assistant sensor alerts (#1148, #2824). Both directions of this
+        # file are hand-maintained field maps, so a column missing here reads
+        # back as the schema default (False) no matter what the row holds.
+        "on_ha_sensor_alert": provider.on_ha_sensor_alert,
+        "on_location_ha_sensor_alert": provider.on_location_ha_sensor_alert,
         # AMS environmental alarms (regular AMS)
         "on_ams_humidity_high": provider.on_ams_humidity_high,
         "on_ams_temperature_high": provider.on_ams_temperature_high,
@@ -65,6 +70,11 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
         "on_bed_cooled": provider.on_bed_cooled,
         # First layer complete
         "on_first_layer_complete": provider.on_first_layer_complete,
+        # Inventory stock alerts. Absent here, the toggles above always read
+        # back off no matter what the row holds — the same hand-maintained
+        # field map the Home Assistant comment warns about.
+        "on_stock_reorder_alert": provider.on_stock_reorder_alert,
+        "on_stock_break_alert": provider.on_stock_break_alert,
         # Print queue events
         "on_queue_job_added": provider.on_queue_job_added,
         "on_queue_job_assigned": provider.on_queue_job_assigned,
@@ -135,6 +145,9 @@ async def create_notification_provider(
         on_ai_failure_detection=provider_data.on_ai_failure_detection,
         on_filament_low=provider_data.on_filament_low,
         on_maintenance_due=provider_data.on_maintenance_due,
+        # Home Assistant sensor alerts (#1148, #2824)
+        on_ha_sensor_alert=provider_data.on_ha_sensor_alert,
+        on_location_ha_sensor_alert=provider_data.on_location_ha_sensor_alert,
         # 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,
@@ -149,6 +162,9 @@ async def create_notification_provider(
         on_bed_cooled=provider_data.on_bed_cooled,
         # First layer complete
         on_first_layer_complete=provider_data.on_first_layer_complete,
+        # Inventory stock alerts
+        on_stock_reorder_alert=provider_data.on_stock_reorder_alert,
+        on_stock_break_alert=provider_data.on_stock_break_alert,
         # Print queue events
         on_queue_job_added=provider_data.on_queue_job_added,
         on_queue_job_assigned=provider_data.on_queue_job_assigned,

+ 7 - 1
backend/app/api/routes/obico.py

@@ -53,11 +53,17 @@ async def get_printer_status(
     # Error strings can embed configured URLs (ML API base, external URL), so
     # they stay behind settings:read like the rest of the configuration.
     can_see_error = user is None or user.has_permission(Permission.SETTINGS_READ.value)
+    per_printer = obico_detection_service.get_per_printer()
+    if not can_see_error:
+        # The "error" *class* is not configuration — a printers:read user still
+        # needs to know their print is not being watched. Only the reason, which
+        # can name a URL, is withheld.
+        per_printer = {pid: {**entry, "error": None} for pid, entry in per_printer.items()}
     return {
         "enabled": settings["enabled"],
         # None = all printers are monitored
         "monitored_printers": sorted(enabled_printers) if enabled_printers is not None else None,
-        "per_printer": obico_detection_service.get_per_printer(),
+        "per_printer": per_printer,
         "last_error": obico_detection_service._last_error if can_see_error else None,
     }
 

+ 6 - 0
backend/app/api/routes/print_log.py

@@ -237,6 +237,12 @@ _FAILURE_REASON_KEYS = frozenset(
         "underExtrusion",
         "powerFailure",
         "userCancelled",
+        # Written by the two stale-archive paths in main.py when no end-of-print
+        # status ever arrived (issue #2974). It has to be in the vocabulary, not
+        # just tolerated: the archive editor clears any stored value it does not
+        # recognise, so leaving it out would delete the classification on the
+        # next save of such an archive.
+        "noStatusUpdate",
         "other",
     }
 )

+ 89 - 3
backend/app/api/routes/print_queue.py

@@ -50,6 +50,7 @@ from backend.app.services.print_batch import (
     dispatch_remaining,
     load_progress,
     refresh_batch_status,
+    refresh_batch_status_for_item,
 )
 from backend.app.services.print_cost_estimate import estimate_queue_source_cost
 from backend.app.utils.printer_models import (
@@ -201,6 +202,65 @@ def _assert_can_queue_library_file(library_file: LibraryFile, current_user: User
         raise HTTPException(404, "Library file not found")
 
 
+async def _is_orders_last_source(db: AsyncSession, item: PrintQueueItem) -> bool:
+    """True when deleting *item* would leave an order owing runs it can't queue.
+
+    Dispatch produces the runs an order still owes by cloning an existing
+    queue item for the same plate — that row is the only record of the printer
+    target, AMS mapping and print options the user chose. Delete the last one
+    while the plate still has a target and the order is stranded: it goes on
+    reporting work outstanding with no way left to produce it (#2960).
+    """
+    if item.batch_id is None:
+        return False
+
+    # A cancelled order can never dispatch again, so nothing about it can be
+    # stranded and its leftover rows must stay deletable — tidying up after a
+    # cancel is the most likely reason anyone deletes them.
+    status = (await db.execute(select(PrintBatch.status).where(PrintBatch.id == item.batch_id))).scalar_one_or_none()
+    if status == "cancelled":
+        return False
+
+    plate_scope = (
+        PrintBatchPlate.plate_id == item.plate_id if item.plate_id is not None else PrintBatchPlate.plate_id.is_(None)
+    )
+    # first(), not scalar_one_or_none(): a UNIQUE(batch_id, plate_id) does not
+    # constrain NULL plate_ids on either dialect, and a duplicate whole-file
+    # row must not turn a delete into a 500.
+    target = (
+        (
+            await db.execute(
+                select(PrintBatchPlate.quantity_target)
+                .where(PrintBatchPlate.batch_id == item.batch_id)
+                .where(plate_scope)
+                .order_by(PrintBatchPlate.quantity_target.desc())
+                .limit(1)
+            )
+        )
+        .scalars()
+        .first()
+    )
+    # No target row: a grouping, or a plate assigned into the order by hand.
+    # A target of 0 is legal and means the plate is not wanted. Neither owes
+    # anything, so neither can be stranded.
+    if not target:
+        return False
+
+    item_scope = (
+        PrintQueueItem.plate_id == item.plate_id if item.plate_id is not None else PrintQueueItem.plate_id.is_(None)
+    )
+    survivor = (
+        await db.execute(
+            select(PrintQueueItem.id)
+            .where(PrintQueueItem.batch_id == item.batch_id)
+            .where(item_scope)
+            .where(PrintQueueItem.id != item.id)
+            .limit(1)
+        )
+    ).scalar_one_or_none()
+    return survivor is None
+
+
 async def _assert_can_dispatch_batch_sources(db: AsyncSession, batch_id: int, current_user: User | None) -> None:
     """Apply the ``POST /queue/`` source-file gates to everything a dispatch would print.
 
@@ -1743,6 +1803,7 @@ async def _build_batch_response(
         has_targets=progress.has_targets,
         target_count=progress.target,
         remaining_count=progress.remaining,
+        dispatchable_count=progress.dispatchable_remaining,
         actual_cost=progress.actual_cost,
         estimated_remaining_cost=progress.estimated_remaining_cost,
         filament_used_grams=progress.filament_used_grams,
@@ -1764,6 +1825,7 @@ async def _build_batch_response(
                 estimated_remaining_cost=plate.estimated_remaining_cost,
                 filament_used_grams=plate.filament_used_grams,
                 print_time_seconds=plate.print_time_seconds,
+                can_dispatch=plate.can_dispatch,
             )
             for plate in progress.plates
         ],
@@ -1992,7 +2054,15 @@ async def delete_queue_item(
         )
     ),
 ):
-    """Remove an item from the queue."""
+    """Remove an item from the queue.
+
+    An order's last surviving run for a plate is cancelled instead of deleted
+    (#2960). The row is what a later dispatch clones, so removing it would
+    leave the order reporting work outstanding that nothing could ever
+    produce. A completed run is exempt: it is the record of something that was
+    actually made, and rewriting it as cancelled would falsify the order's
+    progress.
+    """
     user, can_modify_all = auth_result
 
     result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
@@ -2008,13 +2078,22 @@ async def delete_queue_item(
     if item.status == "printing":
         raise HTTPException(400, "Cannot delete item that is currently printing")
 
+    keep_as_cancelled = item.status != "completed" and await _is_orders_last_source(db, item)
+
     await release_budget_reservation(
         db,
         source_type="print_queue",
         source_id=item.id,
         status="released",
     )
-    await db.delete(item)
+    if keep_as_cancelled:
+        item.status = "cancelled"
+        await db.flush()
+        # The order may have been sitting on "completed" if this run's target
+        # was met by it; cancelling reopens it.
+        await refresh_batch_status_for_item(db, item.id)
+    else:
+        await db.delete(item)
     await db.commit()
 
     # Stop an in-flight preheat for this item: the dispatch coroutine is
@@ -2023,8 +2102,15 @@ async def delete_queue_item(
 
     _scheduler.notify_dispatch_cancelled(item_id)
 
+    if keep_as_cancelled:
+        logger.info("Kept queue item %s as cancelled — last source for its batch order plate", item_id)
+        return {
+            "message": "Item cancelled rather than deleted: it is the only run the order can re-queue this plate from",
+            "deleted": False,
+        }
+
     logger.info("Deleted queue item %s", item_id)
-    return {"message": "Queue item deleted"}
+    return {"message": "Queue item deleted", "deleted": True}
 
 
 @router.post("/reorder")

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 546 - 137
backend/app/api/routes/printers.py


+ 107 - 0
backend/app/api/routes/scheduled_dryings.py

@@ -0,0 +1,107 @@
+"""Scheduled (delayed) manual AMS drying runs (#2638)."""
+
+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.scheduled_drying import ScheduledDrying
+from backend.app.models.user import User
+from backend.app.schemas.scheduled_drying import ScheduledDryingCreate, ScheduledDryingResponse
+from backend.app.services import drying_preflight
+from backend.app.services.printer_manager import printer_manager
+from backend.app.utils.local_time import utcnow_naive
+
+router = APIRouter(prefix="/scheduled-dryings", tags=["scheduled-dryings"])
+
+ACTIVE_STATUSES = ("pending", "running")
+# Failed rows are listed too. A run can only fail at dispatch (the firmware
+# turns out too old, say), which is exactly the case a schedule-time check on
+# an offline printer cannot catch, so without this the run just disappears and
+# only the backend log knows why. The client dismisses the row to clear it.
+LISTED_STATUSES = (*ACTIVE_STATUSES, "failed")
+
+
+@router.post("", response_model=ScheduledDryingResponse)
+async def create_scheduled_drying(
+    payload: ScheduledDryingCreate,
+    user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    result = await db.execute(select(Printer).where(Printer.id == payload.printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    # Fail fast in the UI rather than hours later with nobody watching. An
+    # offline printer is still schedulable: only its model is judged here.
+    state = printer_manager.get_status(payload.printer_id)
+    unsupported = drying_preflight.check_drying_supported(
+        printer.model,
+        state.firmware_version if state else None,
+        require_firmware=state is not None,
+    )
+    if unsupported:
+        raise HTTPException(400, unsupported)
+
+    if payload.start_after is not None and payload.start_after <= utcnow_naive():
+        raise HTTPException(400, "start_after must be in the future")
+
+    row = ScheduledDrying(
+        printer_id=payload.printer_id,
+        ams_id=payload.ams_id,
+        temp=payload.temp,
+        duration_hours=payload.duration_hours,
+        filament=payload.filament,
+        rotate_tray=payload.rotate_tray,
+        start_after=payload.start_after,
+        created_by_id=user.id if user else None,
+    )
+    db.add(row)
+    await db.commit()
+    await db.refresh(row)
+    return row
+
+
+@router.get("", response_model=list[ScheduledDryingResponse])
+async def list_scheduled_dryings(
+    printer_id: int | None = None,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    query = select(ScheduledDrying).where(ScheduledDrying.status.in_(LISTED_STATUSES))
+    if printer_id is not None:
+        query = query.where(ScheduledDrying.printer_id == printer_id)
+    result = await db.execute(query.order_by(ScheduledDrying.start_after.asc().nullsfirst(), ScheduledDrying.id.asc()))
+    return list(result.scalars().all())
+
+
+@router.delete("/{scheduled_drying_id}")
+async def cancel_scheduled_drying(
+    scheduled_drying_id: int,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    result = await db.execute(select(ScheduledDrying).where(ScheduledDrying.id == scheduled_drying_id))
+    row = result.scalar_one_or_none()
+    if not row:
+        raise HTTPException(404, "Scheduled drying not found")
+    if row.status == "failed":
+        # Terminal and now acknowledged; drop it so it stops being listed.
+        await db.delete(row)
+        await db.commit()
+        return {"status": "dismissed", "id": scheduled_drying_id}
+    if row.status not in ACTIVE_STATUSES:
+        raise HTTPException(400, "Only pending, running or failed dryings can be cancelled")
+
+    if row.status == "running":
+        # Best effort; cancellation proceeds even if the printer is offline.
+        printer_manager.send_drying_command(row.printer_id, row.ams_id, 0, 0, mode=0)
+
+    row.status = "cancelled"
+    row.completed_at = utcnow_naive()
+    await db.commit()
+    return {"status": "cancelled", "id": row.id}

+ 35 - 17
backend/app/api/routes/settings.py

@@ -8,7 +8,7 @@ from pathlib import Path
 from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
 from fastapi.responses import FileResponse, JSONResponse
 from pydantic import BaseModel, Field
-from sqlalchemy import delete, func, select
+from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.auth import RequirePermissionIfAuthEnabled, caller_is_api_key, require_energy_cost_update
@@ -212,6 +212,16 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
             "low_stock_threshold",
         ]:
             settings_dict[setting.key] = float(setting.value)
+        elif setting.key in [
+            # Nullable floats. Settings storage stringifies None to the literal
+            # "None", so these cannot go in the list above -- float("None")
+            # raises and would take the whole settings response with it (#2905).
+            "ams_temp_alarm",
+        ]:
+            try:
+                settings_dict[setting.key] = float(setting.value)
+            except (TypeError, ValueError):
+                settings_dict[setting.key] = None
         elif setting.key in [
             "ams_humidity_good",
             "ams_humidity_fair",
@@ -224,6 +234,7 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
             "stagger_group_size",
             "stagger_interval_minutes",
             "forecast_global_lead_time_days",
+            "location_sensor_poll_interval",
             "finance_budget_reset_day",
             "session_max_hours",
             "pipeline_max_copies",
@@ -446,6 +457,11 @@ _UI_PREFERENCE_FIELDS: tuple[str, ...] = (
     "ams_humidity_fair",
     "ams_temp_good",
     "ams_temp_fair",
+    # ams_temp_alarm is deliberately NOT here. This endpoint is unauthenticated
+    # and exists so the UI can colour readings without SETTINGS_READ; the good /
+    # fair bands are what the printer card colours by. The alarm threshold
+    # changes no rendering anywhere -- only SettingsPage reads it, and that is
+    # behind the settings permissions already (#2905).
     "bed_cooled_threshold",
     # Temperature / fan-speed presets for the printer-card popovers. Numbers
     # only; no PII / credentials.
@@ -536,22 +552,24 @@ async def update_spoolman_settings(
         now_enabled = new_val == "true"
         await set_setting(db, "spoolman_enabled", new_val)
 
-        # Switching to Spoolman: clear built-in inventory slot assignments
-        if not was_enabled and now_enabled:
-            from backend.app.models.spool_assignment import SpoolAssignment
-
-            result = await db.execute(delete(SpoolAssignment))
-            logger.info("Cleared %d spool assignments on switch to Spoolman mode", result.rowcount)
-        # Switching back to internal mode: clear Spoolman slot assignments — the
-        # symmetric counterpart of the clear above. Without this, stale
-        # spoolman_slot_assignments rows linger and would wrongly count as
-        # "assigned" in any mode-agnostic check (e.g. the missing-spool-
-        # assignment notification, which unions both tables — #1473).
-        elif was_enabled and not now_enabled:
-            from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
-
-            result = await db.execute(delete(SpoolmanSlotAssignment))
-            logger.info("Cleared %d Spoolman slot assignments on switch to internal mode", result.rowcount)
+        # Nothing is deleted on a mode change (#2812). Each mode keeps its slot
+        # assignments in its own table, so both can hold rows at once and the
+        # toggle is reversible: switching to Spoolman to see what it does, then
+        # switching back, returns you to the assignments you had.
+        #
+        # This used to empty the other mode's table on every toggle. The reason
+        # was real -- checks that read both tables would let a row in the mode
+        # you are not using answer for the mode you are -- but the cost was that
+        # inspecting a mode destroyed your configuration, with no confirmation
+        # and no way back, and the deletion was unfiltered across every printer.
+        # The readers that could be confused now ask which mode is active
+        # (``spoolman_owns_assignments``), which is where that decision belongs:
+        # the mode is a property of the install, not of the rows.
+        if was_enabled != now_enabled:
+            logger.info(
+                "Inventory mode switched to %s; slot assignments in both tables kept",
+                "Spoolman" if now_enabled else "built-in",
+            )
     if "spoolman_url" in settings:
         await set_setting(db, "spoolman_url", normalize_str_setting("spoolman_url", settings["spoolman_url"]))
     if "spoolman_sync_mode" in settings:

+ 23 - 7
backend/app/api/routes/slicer_presets.py

@@ -309,7 +309,10 @@ async def _fetch_local_presets(db: AsyncSession) -> dict[str, list[UnifiedPreset
 
 
 def _content_compatible_printers(content: dict) -> list[str] | None:
-    """Pull ``compatible_printers`` out of an inline profile content dict.
+    """Pull ``compatible_printers`` out of a profile content dict.
+
+    Serves both callers that have one: an Orca Cloud profile's inline
+    ``content``, and an entry of the sidecar's bundled listing.
 
     Orca profiles carry it as a list of printer-preset names (the same shape
     ``orca_profiles.py`` stores on import); a single-printer profile may store
@@ -403,13 +406,26 @@ async def _fetch_bundled_presets(db: AsyncSession, *, refresh: bool = False) ->
                 continue
             # Bundled presets are addressed by name (the slicer resolves them
             # by name during the `inherits:` walk), so name doubles as id.
-            extra: dict[str, str | None] = {}
+            preset = UnifiedPreset(id=name, name=name, source="standard")
             if slot == "filament":
-                extra["filament_type"] = entry.get("filament_type")
-                extra["filament_colour"] = entry.get("filament_colour")
-            slots[slot].append(
-                UnifiedPreset(id=name, name=name, source="standard", **extra),
-            )
+                preset.filament_type = entry.get("filament_type")
+                preset.filament_colour = entry.get("filament_colour")
+            if slot in ("process", "filament"):
+                # The slicer's own compatible-printer list, and the only
+                # truthful answer for several Bambu printers: the bundle ships
+                # no process preset named after a P1S, an X1, an X1E or an H2D
+                # Pro -- each one is served by another model's preset that
+                # names it here. Inferring the printer from the preset NAME
+                # instead read all 198 as belonging to the model in their
+                # `@BBL` tag, so a P1S had zero compatible processes, the
+                # dropdown hid every one of them, and the auto-pick landed on
+                # an A1 0.2-nozzle process the CLI then refused (#2982).
+                #
+                # Older sidecars don't report the field. They return None here,
+                # which leaves the SliceModal on the name matcher for the
+                # standard tier -- degraded exactly as before, not broken.
+                preset.compatible_printers = _content_compatible_printers(entry)
+            slots[slot].append(preset)
 
     _bundled_cache = (now, slots)
     return slots

+ 104 - 31
backend/app/api/routes/spoolman.py

@@ -22,6 +22,9 @@ from backend.app.models.spoolman_k_profile import SpoolmanKProfile
 from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 from backend.app.models.user import User
 from backend.app.services.printer_manager import printer_manager
+from backend.app.services.slicer_filament_resolver import resolve_slicer_filament
+from backend.app.services.slot_nozzle import resolve_slot_nozzle
+from backend.app.services.spool_filament_preset import resolve_spoolman_preset
 from backend.app.services.spoolman import (
     SpoolmanClientError,
     SpoolmanNotFoundError,
@@ -32,9 +35,10 @@ from backend.app.services.spoolman import (
 )
 from backend.app.utils.filament_ids import (
     GENERIC_FILAMENT_IDS,
-    MATERIAL_TEMPS,
+    filament_id_to_setting_id,
     normalize_slicer_filament,
 )
+from backend.app.utils.filament_types import nozzle_temp_range, printer_filament_type
 
 logger = logging.getLogger(__name__)
 
@@ -100,14 +104,47 @@ async def get_spoolman_status(
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
 ):
-    """Get Spoolman integration status."""
+    """Get Spoolman integration status.
+
+    ``connected`` answers "does the configured Spoolman respond?", which means
+    asking it. It used to answer "has some earlier request in this process left
+    a client object lying around?" -- and roughly twenty call sites build one
+    lazily, so the answer depended on which page happened to load first rather
+    than on anything about Spoolman.
+
+    That mattered because the UI reads this one flag twice: it offers Connect
+    only while disconnected, and the AMS sync section only while connected.
+    Saving the Settings page initialises a client as a side effect of syncing
+    locations, so enabling Spoolman there reported "connected" without anything
+    having been set up, hiding the Connect button and revealing a sync that then
+    failed on every slot (issue #2903). Registration no longer depends on that
+    button, but the flag was still describing Bambuddy's memory rather than the
+    integration, so it is now resolved the same way every other route resolves
+    it -- including the stale-URL check, so editing the URL is not reported
+    against the old host.
+    """
     sm = await get_spoolman_settings(db)
     enabled, url = sm["enabled"], sm["url"]
 
-    client = await get_spoolman_client()
     connected = False
-    if client:
-        connected = await client.health_check()
+    if enabled and url:
+        client = await get_spoolman_client()
+        if not client or client.base_url != url.rstrip("/"):
+            try:
+                client = await init_spoolman_client(url)
+            except ValueError as exc:
+                logger.warning("Spoolman URL %r rejected by SSRF guard during status check: %s", url, exc)
+                client = None
+            except Exception as exc:
+                # Every remaining way this can fail still answers the question:
+                # replacing a client closes the previous one, and httpx's
+                # aclose() is not guaranteed not to raise. A status poll that
+                # 500s every 30 seconds is worse than one reporting what is
+                # true either way -- that Spoolman could not be reached.
+                logger.warning("Could not open a Spoolman client for %r during status check: %s", url, exc)
+                client = None
+        if client:
+            connected = await client.health_check()
 
     return SpoolmanStatus(
         enabled=enabled,
@@ -904,40 +941,79 @@ async def link_spool(
 
             mqtt_client = printer_manager.get_client(p_id)
             if mqtt_client:
-                tray_type = mapped.get("material") or ""
+                # Spoolman's material is free text, so it arrives as whatever
+                # the user typed there -- "PLA+", "PolyTerra PLA". The sub-brand
+                # keeps that wording; the slot's type has to be one the printer
+                # and the slicer know (issue #2902).
+                material = mapped.get("material") or ""
+                tray_type = printer_filament_type(material)
                 brand = mapped.get("brand") or ""
                 subtype = mapped.get("subtype") or ""
                 if brand:
-                    tray_sub_brands = f"{brand} {tray_type} {subtype}".strip()
+                    tray_sub_brands = f"{brand} {material} {subtype}".strip()
                 elif subtype:
-                    tray_sub_brands = f"{tray_type} {subtype}".strip()
+                    tray_sub_brands = f"{material} {subtype}".strip()
                 else:
-                    tray_sub_brands = tray_type
+                    tray_sub_brands = material
 
                 tray_color = (mapped.get("rgba") or "808080FF").upper()
                 if len(tray_color) == 6:
                     tray_color = tray_color + "FF"
 
-                material_upper = tray_type.upper().strip()
-                tray_info_idx = (
-                    GENERIC_FILAMENT_IDS.get(material_upper)
-                    or GENERIC_FILAMENT_IDS.get(material_upper.split("-")[0].split(" ")[0])
-                    or ""
-                )
-                setting_id = ""
-                temp_defaults = MATERIAL_TEMPS.get(material_upper, (200, 240))
-                temp_min = mapped.get("nozzle_temp_min") or temp_defaults[0]
-                temp_max = temp_defaults[1]
-
                 # Pull printer state via printer_manager (mqtt_client.printer_state
                 # was a non-existent attribute — the hasattr check silently
                 # returned None, defeating every state-based lookup below).
                 state = printer_manager.get_status(p_id)
-                nozzle_diameter = "0.4"
-                if state and state.nozzles:
-                    nd = state.nozzles[0].nozzle_diameter
-                    if nd:
-                        nozzle_diameter = nd
+                slot_nozzle = resolve_slot_nozzle(state, a_id, t_id, printer_manager.get_model(p_id))
+                nozzle_diameter = slot_nozzle.diameter
+
+                # Resolve the spool's own preset before falling back to a
+                # generic material id. This path used to skip that entirely and
+                # configure every linked slot as generic PLA/PETG, so a spool
+                # with a preset set in inventory lost it the moment it was
+                # linked by tag — the same defect #1713 fixed on the assign
+                # path, in the function next door. The per-model override
+                # cascade applies here for the same reason it does there: the
+                # preset is bound to a printer model.
+                slot_slicer_filament, slot_slicer_filament_name = await resolve_spoolman_preset(
+                    db,
+                    spoolman_spool_id=spool_id,
+                    printer_model=printer_manager.get_model(p_id),
+                    nozzle_diameter=nozzle_diameter,
+                    fallback_filament=mapped.get("slicer_filament"),
+                    fallback_name=mapped.get("slicer_filament_name"),
+                )
+                tray_info_idx, setting_id, sub_brand_override, type_override = await resolve_slicer_filament(
+                    db=db,
+                    current_user=None,
+                    slicer_filament=slot_slicer_filament,
+                    slicer_filament_name=slot_slicer_filament_name,
+                    material=material,
+                )
+                if sub_brand_override:
+                    tray_sub_brands = sub_brand_override
+                if type_override:
+                    tray_type = printer_filament_type(type_override)
+
+                # The spool's own wording is tried first and the reduced type
+                # only as a further fallback, so a material that already
+                # resolves keeps resolving to the same id: "PETG HF" has its
+                # own generic preset (GFG96) that reducing it to "PETG" would
+                # trade away for GFG99.
+                material_upper = material.upper().strip()
+                if not tray_info_idx:
+                    tray_info_idx = (
+                        GENERIC_FILAMENT_IDS.get(material_upper)
+                        or GENERIC_FILAMENT_IDS.get(material_upper.split("-")[0].split(" ")[0])
+                        or GENERIC_FILAMENT_IDS.get(tray_type.upper())
+                        or ""
+                    )
+                if tray_info_idx and not setting_id:
+                    setting_id = filament_id_to_setting_id(tray_info_idx)
+
+                temp_defaults = nozzle_temp_range(material, tray_type)
+                temp_min = mapped.get("nozzle_temp_min") or temp_defaults[0]
+                temp_max = temp_defaults[1]
 
                 kp_result = await db.execute(
                     select(SpoolmanKProfile).where(
@@ -946,12 +1022,7 @@ async def link_spool(
                     )
                 )
                 kp_rows = kp_result.scalars().all()
-                slot_extruder = None
-                if state and state.ams_extruder_map:
-                    if a_id == 255:
-                        slot_extruder = 1 - t_id
-                    else:
-                        slot_extruder = state.ams_extruder_map.get(str(a_id))
+                slot_extruder = slot_nozzle.extruder
 
                 # Prefer exact extruder match, fall back to extruder-agnostic kp
                 # for the same nozzle. Hard-skip on extruder mismatch silently
@@ -962,6 +1033,8 @@ async def link_spool(
                 for kp in kp_rows:
                     if kp.nozzle_diameter != nozzle_diameter or kp.cali_idx is None:
                         continue
+                    if not slot_nozzle.flow_matches(kp.nozzle_type):
+                        continue
                     if slot_extruder is not None and kp.extruder is not None and kp.extruder == slot_extruder:
                         exact_kp = kp
                         break

+ 159 - 33
backend/app/api/routes/spoolman_inventory.py

@@ -38,10 +38,11 @@ from backend.app.core.websocket import ws_manager
 from backend.app.models.ams_label import AmsLabel
 from backend.app.models.printer import Printer
 from backend.app.models.settings import Settings
+from backend.app.models.spool_filament_preset import SpoolmanFilamentPreset
 from backend.app.models.spoolman_k_profile import SpoolmanKProfile
 from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 from backend.app.models.user import User
-from backend.app.schemas.spool import SpoolKProfileBase
+from backend.app.schemas.spool import SpoolFilamentPresetBase, SpoolKProfileBase
 from backend.app.schemas.spoolman import SpoolmanFilamentPatch, SpoolmanSlotAssignmentEnriched
 from backend.app.services.location_service import (
     enrich_spool_dicts_with_location_id,
@@ -50,6 +51,8 @@ from backend.app.services.location_service import (
 )
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.slicer_filament_resolver import resolve_slicer_filament
+from backend.app.services.slot_nozzle import resolve_slot_nozzle
+from backend.app.services.spool_filament_preset import resolve_spoolman_preset
 from backend.app.services.spoolman import (
     SpoolmanClient,
     SpoolmanClientError,
@@ -59,12 +62,13 @@ from backend.app.services.spoolman import (
     init_spoolman_client,
 )
 from backend.app.services.spoolman_tracking import get_fallback_spool_tag_for_slot
+from backend.app.utils.color_utils import spoolman_color_hex
 from backend.app.utils.filament_ids import (
     GENERIC_FILAMENT_IDS,
-    MATERIAL_TEMPS,
     filament_id_to_setting_id,
     normalize_slicer_filament,
 )
+from backend.app.utils.filament_types import nozzle_temp_range, printer_filament_type
 
 logger = logging.getLogger(__name__)
 
@@ -502,7 +506,10 @@ async def _resolve_filament_id(data: SpoolmanInventoryCreate, client: SpoolmanCl
         return data.spoolman_filament_id
     # Validator guarantees material is non-None when spoolman_filament_id is None
     assert data.material is not None  # noqa: S101
-    color_hex = (data.rgba or "808080FF")[:6]
+    # `or "808080"` on the result rather than on the input: spoolman_color_hex
+    # returns None only for a missing value, so this is the same neutral grey the
+    # old inline default produced, without handing an Optional to a str parameter.
+    color_hex = spoolman_color_hex(data.rgba) or "808080"
     async with _translate_spoolman_errors():
         return await client.find_or_create_filament(
             material=data.material,
@@ -708,7 +715,10 @@ async def update_spool(
     else:
         color_name = cur_filament.get("color_name") or None
     cur_color = (cur_filament.get("color_hex") or "808080").upper().removeprefix("#")
-    rgba = data.rgba if data.rgba is not None else (cur_color + "FF")
+    # Handed over as stored. The opaque alpha this used to append was folded
+    # straight back off by `spoolman_color_hex` below, so the two paths landed on
+    # the same string and the append only obscured which shape was in hand (#2912).
+    rgba = data.rgba if data.rgba is not None else cur_color
     label_weight = data.label_weight if data.label_weight is not None else int(cur_filament.get("weight") or 1000)
     # Default weight_used from the synthetic mapping (label - remaining) so an
     # edit that doesn't touch the weight field preserves Spoolman's real
@@ -736,7 +746,7 @@ async def update_spool(
         except ValueError as exc:
             raise HTTPException(status_code=400, detail=str(exc)) from exc
 
-    color_hex = rgba[:6]
+    color_hex = spoolman_color_hex(rgba) or rgba
 
     # Resolve which filament this spool should be linked to AFTER the edit.
     #
@@ -749,14 +759,18 @@ async def update_spool(
     # filament in place when it's a singleton.
     cur_filament_id = cur_filament.get("id")
     desired_name = f"{material} {subtype}".strip() if subtype else material
-    cur_color_norm = (cur_filament.get("color_hex") or "").upper()[:6]
+    # Compare the stored shapes, not raw strings and not bare RGB prefixes. Raw
+    # strings make an opaque spool's six characters differ from an incoming eight
+    # and PATCH the filament on every no-op edit; bare prefixes make an
+    # alpha-only edit invisible so the change never lands (#2912).
+    cur_color_norm = spoolman_color_hex(cur_filament.get("color_hex")) or ""
     cur_vendor_name = (cur_vendor.get("name") or "").strip()
     cur_weight_int = int(cur_filament.get("weight") or 0)
     metadata_unchanged = (
         cur_filament_id
         and (cur_filament.get("name") or "").strip() == desired_name
         and (cur_filament.get("material") or "").upper() == material.upper()
-        and cur_color_norm == color_hex.upper()
+        and cur_color_norm == (color_hex or "").upper()
         and cur_vendor_name.lower() == ((brand or "").strip().lower())
         and cur_weight_int == int(label_weight)
     )
@@ -1477,20 +1491,52 @@ async def assign_spoolman_slot(
     try:
         mqtt_client = printer_manager.get_client(body.printer_id)
         if mqtt_client:
-            tray_type = mapped.get("material") or ""
+            # Spoolman's material is free text, so it arrives as whatever the
+            # user typed there -- "PLA+", "PolyTerra PLA". The sub-brand keeps
+            # that wording; the slot's type has to be one the printer and the
+            # slicer know (issue #2902).
+            material = mapped.get("material") or ""
+            tray_type = printer_filament_type(material)
             brand = mapped.get("brand") or ""
             subtype = mapped.get("subtype") or ""
             if brand:
-                tray_sub_brands = f"{brand} {tray_type} {subtype}".strip()
+                tray_sub_brands = f"{brand} {material} {subtype}".strip()
             elif subtype:
-                tray_sub_brands = f"{tray_type} {subtype}".strip()
+                tray_sub_brands = f"{material} {subtype}".strip()
             else:
-                tray_sub_brands = tray_type
+                tray_sub_brands = material
 
             tray_color = (mapped.get("rgba") or "808080FF").upper()
             if len(tray_color) == 6:
                 tray_color = tray_color + "FF"
 
+            # Printer state, read here rather than further down because the
+            # per-model preset override below needs the slot's nozzle
+            # diameter and the K-profile cascade further down needs the same
+            # value -- one read, so they cannot disagree. (The previous
+            # `mqtt_client.printer_state` access via hasattr always returned
+            # None -- the attribute is `state`, not `printer_state` -- so the
+            # K-profile cascade silently skipped state.kprofiles, defaulted
+            # nozzle_diameter to 0.4, and left slot_extruder unset.)
+            state = printer_manager.get_status(body.printer_id)
+            slot_nozzle = resolve_slot_nozzle(
+                state, body.ams_id, body.tray_id, printer_manager.get_model(body.printer_id)
+            )
+            nozzle_diameter = slot_nozzle.diameter
+
+            # Per-printer-model preset override, same cascade as internal
+            # mode: a cloud/Orca preset is bound to a model, so one stored
+            # preset per spool is wrong across two models. Returns Spoolman's
+            # own value when no override is set.
+            slot_slicer_filament, slot_slicer_filament_name = await resolve_spoolman_preset(
+                db,
+                spoolman_spool_id=body.spoolman_spool_id,
+                printer_model=printer_manager.get_model(body.printer_id),
+                nozzle_diameter=nozzle_diameter,
+                fallback_filament=mapped.get("slicer_filament"),
+                fallback_name=mapped.get("slicer_filament_name"),
+            )
+
             # #1713: resolve the spool's stored slicer_filament reference
             # (cloud preset, local preset, GF-prefix builtin, or numeric
             # LocalPreset id) to the printer-side tray_info_idx + setting_id.
@@ -1499,24 +1545,33 @@ async def assign_spoolman_slot(
             # configured profile never reached the printer. Shared with the
             # internal-mode route via the same helper so the two flows can't
             # drift again.
-            tray_info_idx, setting_id, sub_brand_override = await resolve_slicer_filament(
+            tray_info_idx, setting_id, sub_brand_override, type_override = await resolve_slicer_filament(
                 db=db,
                 current_user=current_user,
-                slicer_filament=mapped.get("slicer_filament"),
-                slicer_filament_name=mapped.get("slicer_filament_name"),
-                material=tray_type,
+                slicer_filament=slot_slicer_filament,
+                slicer_filament_name=slot_slicer_filament_name,
+                material=material,
             )
             if sub_brand_override:
                 tray_sub_brands = sub_brand_override
+            # A preset carries its own type; the reduction above only infers
+            # one from Spoolman's free-text material. The preset wins when the
+            # spool has one (issue #2902, @doncaruana).
+            if type_override:
+                tray_type = printer_filament_type(type_override)
 
-            material_upper = tray_type.upper().strip()
+            material_upper = material.upper().strip()
             # Fall back to generic-material id when slicer_filament is empty
             # or the resolver discarded an unresolvable value. Matches the
-            # internal-mode tail in inventory.py:_apply_spool_to_slot_inner.
+            # internal-mode tail in inventory.py:_apply_spool_to_slot_inner,
+            # including the order: the spool's own wording first and the
+            # reduced type only after it, so "PETG HF" keeps its own generic
+            # preset (GFG96) rather than trading it for "PETG"'s GFG99.
             if not tray_info_idx:
                 tray_info_idx = (
                     GENERIC_FILAMENT_IDS.get(material_upper)
                     or GENERIC_FILAMENT_IDS.get(material_upper.split("-")[0].split(" ")[0])
+                    or GENERIC_FILAMENT_IDS.get(tray_type.upper())
                     or ""
                 )
 
@@ -1529,7 +1584,7 @@ async def assign_spoolman_slot(
             if tray_info_idx and not setting_id:
                 setting_id = filament_id_to_setting_id(tray_info_idx)
 
-            temp_defaults = MATERIAL_TEMPS.get(material_upper, (200, 240))
+            temp_defaults = nozzle_temp_range(material, tray_type)
             temp_min = mapped.get("nozzle_temp_min") or temp_defaults[0]
             temp_max = temp_defaults[1]
 
@@ -1538,21 +1593,7 @@ async def assign_spoolman_slot(
             # None (the attribute is `state`, not `printer_state`), so the
             # K-profile cascade silently skipped state.kprofiles, defaulted
             # nozzle_diameter to 0.4, and left slot_extruder unset.
-            state = printer_manager.get_status(body.printer_id)
-            nozzle_diameter = "0.4"
-            if state and state.nozzles:
-                nd = state.nozzles[0].nozzle_diameter
-                if nd:
-                    nozzle_diameter = nd
-
-            slot_extruder = None
-            if state and state.ams_extruder_map:
-                if body.ams_id == 255:
-                    # External slots: ext-L (tray 0) → extruder 1, ext-R (tray 1) → extruder 0
-                    # tray_id 0→1, 1→0
-                    slot_extruder = 1 - body.tray_id
-                else:
-                    slot_extruder = state.ams_extruder_map.get(str(body.ams_id))
+            slot_extruder = slot_nozzle.extruder
 
             # Prefer exact extruder match, fall back to extruder-agnostic kp
             # for the same nozzle. Hard-skipping on mismatch silently dropped
@@ -1562,6 +1603,8 @@ async def assign_spoolman_slot(
             for kp in kp_rows:
                 if kp.nozzle_diameter != nozzle_diameter or kp.cali_idx is None:
                     continue
+                if not slot_nozzle.flow_matches(kp.nozzle_type):
+                    continue
                 if slot_extruder is not None and kp.extruder is not None and kp.extruder == slot_extruder:
                     exact_kp = kp
                     break
@@ -1795,6 +1838,89 @@ def _k_profile_to_dict(p: SpoolmanKProfile) -> dict:
     }
 
 
+def _filament_preset_to_dict(p: SpoolmanFilamentPreset) -> dict:
+    """Manually map SpoolmanFilamentPreset → SpoolFilamentPresetResponse-compatible dict."""
+    return {
+        "id": p.id,
+        "spool_id": p.spoolman_spool_id,
+        "printer_model": p.printer_model,
+        "nozzle_diameter": p.nozzle_diameter,
+        "slicer_filament": p.slicer_filament,
+        "slicer_filament_name": p.slicer_filament_name,
+        "created_at": p.created_at,
+    }
+
+
+@router.get("/spools/{spool_id}/filament-presets")
+async def get_spoolman_filament_presets(
+    spool_id: int = Path(..., gt=0),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+) -> list[dict]:
+    """Return all per-printer-model preset overrides for a Spoolman spool."""
+    await _get_client(db)
+    result = await db.execute(
+        select(SpoolmanFilamentPreset).where(SpoolmanFilamentPreset.spoolman_spool_id == spool_id)
+    )
+    return [_filament_preset_to_dict(p) for p in result.scalars().all()]
+
+
+@router.put("/spools/{spool_id}/filament-presets")
+async def save_spoolman_filament_presets(
+    spool_id: int = Path(..., gt=0),
+    presets: list[SpoolFilamentPresetBase] = Body(...),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+) -> list[dict]:
+    """Replace all per-printer-model preset overrides for a Spoolman spool."""
+    client = await _get_client(db)
+    async with _translate_spoolman_errors():
+        await client.get_spool(spool_id)
+
+    # Same as the internal route: reject a duplicated (model, diameter) before
+    # touching the stored rows, so a bad payload cannot clear what it fails to
+    # replace.
+    seen: set[tuple[str, str]] = set()
+    for preset in presets:
+        key = (preset.printer_model, preset.nozzle_diameter)
+        if key in seen:
+            raise HTTPException(
+                422,
+                f"Duplicate override for model {preset.printer_model!r} nozzle {preset.nozzle_diameter or 'any'!r}",
+            )
+        seen.add(key)
+
+    saved: list[SpoolmanFilamentPreset] = []
+    try:
+        await db.execute(delete(SpoolmanFilamentPreset).where(SpoolmanFilamentPreset.spoolman_spool_id == spool_id))
+        await db.flush()
+        for preset in presets:
+            obj = SpoolmanFilamentPreset(
+                spoolman_spool_id=spool_id,
+                printer_model=preset.printer_model,
+                nozzle_diameter=preset.nozzle_diameter,
+                slicer_filament=preset.slicer_filament,
+                slicer_filament_name=preset.slicer_filament_name,
+            )
+            db.add(obj)
+            saved.append(obj)
+        await db.commit()
+    except IntegrityError as exc:
+        await db.rollback()
+        raise HTTPException(422, "Duplicate or invalid preset override (check model and nozzle uniqueness)") from exc
+    except HTTPException:
+        raise
+    except Exception as exc:
+        await db.rollback()
+        logger.error("Filament preset save for spool %d failed: %s", spool_id, exc)
+        raise HTTPException(500, "Failed to save filament presets") from exc
+
+    for obj in saved:
+        await db.refresh(obj)
+
+    return [_filament_preset_to_dict(p) for p in saved]
+
+
 def _normalize_filament(raw: dict) -> NormalizedFilament | None:
     """Normalise a raw Spoolman filament dict for the frontend catalog picker.
 

+ 7 - 3
backend/app/api/routes/support.py

@@ -42,6 +42,7 @@ from backend.app.services.log_reader import (
 )
 from backend.app.services.network_utils import get_network_interfaces
 from backend.app.services.printer_manager import printer_manager
+from backend.app.utils.local_time import utcnow_naive
 
 router = APIRouter(prefix="/support", tags=["support"])
 logger = logging.getLogger(__name__)
@@ -596,9 +597,12 @@ async def _collect_queue_info(db: AsyncSession) -> dict:
         )
     ).scalar_one_or_none()
     if oldest_row is not None:
-        # created_at is naive in this codebase (server_default=func.now()); compare
-        # against naive utc-now to get the actual age without TZ-conversion surprises.
-        age = (datetime.now() - oldest_row).total_seconds()
+        # created_at is naive in this codebase (server_default=func.now()) and holds
+        # UTC, so the clock on the other side of the subtraction has to be naive UTC
+        # too. datetime.now() is naive *local*: on a UTC+3 host it reported an item
+        # queued five minutes ago as three hours old, and went negative west of
+        # Greenwich (#2855).
+        age = (utcnow_naive() - oldest_row).total_seconds()
         info["oldest_pending_age_seconds"] = int(age)
     else:
         info["oldest_pending_age_seconds"] = None

+ 103 - 0
backend/app/core/auth.py

@@ -4,6 +4,7 @@ import logging
 import os
 import secrets
 import time
+from contextvars import ContextVar
 from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
@@ -1129,6 +1130,15 @@ async def _user_from_api_key(db: AsyncSession, api_key: APIKey) -> User | None:
     return user
 
 
+# The row a successful validation produced for the request in flight. Printer-
+# scoped routes validate the same credential twice -- once in the permission
+# gate, once for the key's printer allowlist -- and a validation is a pbkdf2
+# verify plus a ``last_used`` write, so the second one is pure cost. Keyed by
+# the raw credential so a request carrying two of them can never cross their
+# rows, and held in a ContextVar so it cannot outlive the task that set it.
+_validated_api_key: ContextVar[tuple[str, APIKey] | None] = ContextVar("_validated_api_key", default=None)
+
+
 async def _validate_api_key(db: AsyncSession, api_key_value: str) -> APIKey | None:
     """Validate an API key and return the APIKey object if valid, None otherwise.
 
@@ -1163,6 +1173,7 @@ async def _validate_api_key(db: AsyncSession, api_key_value: str) -> APIKey | No
                 # Update last_used timestamp
                 api_key.last_used = datetime.now(timezone.utc)
                 await db.commit()
+                _validated_api_key.set((api_key_value, api_key))
                 return api_key
     except Exception as e:  # SEC-AUTH-EXC: validation failure returns None; every caller treats None as "invalid key" → 401 (fail-closed)
         logger.warning("API key validation error: %s", e)
@@ -1625,6 +1636,67 @@ def check_printer_access(api_key: APIKey, printer_id: int) -> None:
         )
 
 
+async def validated_api_key_from_request(
+    credentials: HTTPAuthorizationCredentials | None,
+    x_api_key: str | None,
+) -> APIKey | None:
+    """Return the validated API key carried by a request, if any.
+
+    Permission dependencies intentionally return ``None`` for API-key callers so
+    routes do not mistake a key for a user identity. Printer-bound routes still
+    need the key row to enforce ``printer_ids`` after the normal scope/owner
+    permission gate has run. This helper recognizes both supported transports.
+    """
+
+    candidate = x_api_key
+    if candidate is None and credentials is not None and credentials.credentials.startswith("bb_"):
+        candidate = credentials.credentials
+    if candidate is None:
+        return None
+    cached = _validated_api_key.get()
+    if cached is not None and cached[0] == candidate:
+        return cached[1]
+    async with async_session() as db:
+        api_key = await _validate_api_key(db, candidate)
+        if api_key is None:
+            raise HTTPException(
+                status_code=status.HTTP_401_UNAUTHORIZED,
+                detail="Invalid API key",
+                headers={"WWW-Authenticate": "Bearer"},
+            )
+        # Touch the JSON-backed value before detaching the row from the session.
+        _ = api_key.printer_ids
+        return api_key
+
+
+async def current_api_key_if_present(
+    credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+    x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
+) -> APIKey | None:
+    """FastAPI dependency exposing only an authenticated API-key principal."""
+
+    return await validated_api_key_from_request(credentials, x_api_key)
+
+
+def require_printer_permission_if_auth_enabled(permission: str | Permission):
+    """Require a permission and enforce an API key's per-printer allowlist."""
+
+    permission_checker = require_permission_if_auth_enabled(permission)
+
+    async def checker(
+        printer_id: int,
+        credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+        x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
+    ) -> User | None:
+        user = await permission_checker(credentials=credentials, x_api_key=x_api_key)
+        api_key = await validated_api_key_from_request(credentials, x_api_key)
+        if api_key is not None:
+            check_printer_access(api_key, printer_id)
+        return user
+
+    return checker
+
+
 # Convenience dependencies - these are functions that return Depends objects
 def RequireAdmin():
     """Dependency that requires admin role."""
@@ -1834,6 +1906,37 @@ def RequirePermissionIfAuthEnabled(*permissions: str | Permission):
     return Depends(require_permission_if_auth_enabled(*permissions))
 
 
+def RequirePrinterPermissionIfAuthEnabled(permission: str | Permission):
+    """Require a permission plus any API-key ``printer_ids`` restriction."""
+
+    return Depends(require_printer_permission_if_auth_enabled(permission))
+
+
+def probe_permissions_if_auth_enabled(*permissions: str | Permission):
+    """Return permission availability while preserving authentication errors.
+
+    This is for endpoints that can return a useful permission-independent
+    subset. Missing permissions become ``False``; invalid or absent credentials
+    still retain the normal 401 response from the shared permission checker.
+    """
+
+    permission_checker = require_permission_if_auth_enabled(*permissions)
+
+    async def checker(
+        credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+        x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
+    ) -> bool:
+        try:
+            await permission_checker(credentials, x_api_key)
+        except HTTPException as exc:
+            if exc.status_code == status.HTTP_403_FORBIDDEN:
+                return False
+            raise
+        return True
+
+    return checker
+
+
 def require_any_permission_if_auth_enabled(*permissions: str | Permission):
     """Dependency factory that requires AT LEAST ONE of the given permissions when auth is enabled."""
     perm_strings = [p.value if isinstance(p, Permission) else p for p in permissions]

+ 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.3"
+APP_VERSION = "1.2.5.4"
 GITHUB_REPO = "maziggy/bambuddy"
 BUG_REPORT_RELAY_URL = os.environ.get("BUG_REPORT_RELAY_URL", "https://bambuddy.cool/api/bug-report")
 

+ 831 - 27
backend/app/core/database.py

@@ -65,9 +65,43 @@ def _resolve_pool_kwargs() -> dict:
     return kwargs
 
 
+def _resolve_connect_args() -> dict:
+    """Connect args that pin a PostgreSQL session to UTC (issue #2855).
+
+    Bambuddy's ``DateTime`` columns are naive and hold UTC, and the frontend's
+    ``parseUTCDate()`` reads a timestamp with no offset as UTC. Python-side
+    writes honour that (``utcnow_naive()``), but ~96 columns take their value
+    from ``server_default=func.now()`` and the migration DDL has ~49 more on
+    ``DEFAULT CURRENT_TIMESTAMP`` — those are filled by the database, not by us.
+
+    On PostgreSQL ``now()`` is a ``timestamptz``, so storing it into a
+    ``timestamp without time zone`` column casts it through the session
+    ``TimeZone``. A Postgres container started with ``TZ=Europe/Istanbul`` bakes
+    that zone into ``postgresql.conf`` at initdb, and every defaulted timestamp
+    is then written as local wall-clock and rendered three hours in the future.
+    Pinning the session makes the cast a no-op regardless of the server's own
+    setting.
+
+    SQLite needs nothing: its ``CURRENT_TIMESTAMP`` is UTC by definition and has
+    no session timezone to get wrong. This makes Postgres match SQLite rather
+    than introducing a third convention.
+    """
+    if is_sqlite():
+        return {}
+    # asyncpg is the documented driver and sends these in the startup packet;
+    # anything else Postgres goes through libpq, which takes the same setting
+    # as a command-line option.
+    if "+asyncpg" in settings.database_url:
+        return {"server_settings": {"timezone": "UTC"}}
+    return {"options": "-c timezone=UTC"}
+
+
 def _create_engine():
     """Create the async engine with dialect-appropriate settings."""
     kwargs = _resolve_pool_kwargs()
+    connect_args = _resolve_connect_args()
+    if connect_args:
+        kwargs["connect_args"] = connect_args
 
     global _pool_config
     _pool_config = {
@@ -269,6 +303,7 @@ async def init_db():
         library,
         local_preset,
         location,
+        location_ha_sensor,
         long_lived_token,
         maintenance,
         notification,
@@ -285,6 +320,7 @@ async def init_db():
         printer_sensor_history,
         project,
         project_bom,
+        scheduled_drying,
         settings,
         shopping_list,
         slicer_pipeline,
@@ -294,6 +330,7 @@ async def init_db():
         spool,
         spool_assignment,
         spool_catalog,
+        spool_filament_preset,
         spool_k_profile,
         spool_usage_history,
         spoolbuddy_device,
@@ -559,16 +596,64 @@ async def _migrate_encrypt_legacy_secrets() -> None:
         )
 
 
+# PostgreSQL SQLSTATE codes meaning "this DDL statement has already been applied".
+# We classify on these rather than on the error text because the server renders
+# messages in its own ``lc_messages`` locale: a Russian-locale server answers a
+# duplicate ADD COLUMN with "уже существует", which no English substring check can
+# recognise. That made Bambuddy unstartable on every non-English PostgreSQL server,
+# fresh or existing — create_all() runs before run_migrations(), so on a new database
+# essentially every ADD COLUMN below is expected to come back as a duplicate (#2949).
+_PG_ALREADY_APPLIED = frozenset(
+    {
+        "42701",  # duplicate_column — ALTER TABLE ADD COLUMN
+        "42P07",  # duplicate_table — CREATE TABLE, CREATE INDEX
+        "42710",  # duplicate_object — ADD CONSTRAINT, CREATE TRIGGER
+        "23505",  # unique_violation — duplicate key
+    }
+)
+
+# undefined_column. Idempotency only for RENAME COLUMN (the rename already ran);
+# on any other statement a missing column means a broken schema, not a re-run.
+_PG_UNDEFINED_COLUMN = "42703"
+
+
+def _sqlstate(exc) -> str | None:
+    """Return the PostgreSQL SQLSTATE behind a SQLAlchemy error, or None.
+
+    None on SQLite, whose DBAPI exceptions carry no such code — and which never
+    localises its messages, so the text match below stays correct there.
+    """
+    orig = getattr(exc, "orig", None)
+    for attr in ("sqlstate", "pgcode"):
+        code = getattr(orig, attr, None)
+        if code:
+            return str(code)
+    return None
+
+
+def _is_already_applied(exc, sql: str) -> bool:
+    """Return True if a failed DDL statement had simply already been applied."""
+    is_rename = "rename column" in sql.lower()
+
+    state = _sqlstate(exc)
+    if state is not None:
+        return state in _PG_ALREADY_APPLIED or (state == _PG_UNDEFINED_COLUMN and is_rename)
+
+    msg = str(exc).lower()
+    if any(k in msg for k in ("already exists", "duplicate key", "duplicate column name", "no such column")):
+        return True
+    return is_rename and "column" in msg and "does not exist" in msg
+
+
 async def _safe_execute(conn, sql):
     """Execute a DDL migration statement, silently ignoring idempotency errors.
 
-    'already exists', 'duplicate column name' (SQLite ADD COLUMN), 'no such column'
-    (SQLite RENAME COLUMN), 'duplicate key', and the compound
-    'column … does not exist' (PostgreSQL RENAME COLUMN idempotency) are swallowed
-    so that re-running DDL migrations is safe.  The compound check additionally
-    requires the SQL to be a RENAME COLUMN statement so that "does not exist" errors
-    from ADD COLUMN or CREATE INDEX (which would indicate schema corruption, not
-    idempotency) are never silently swallowed.
+    Statements that had already been applied are swallowed so that re-running DDL
+    migrations is safe — see :func:`_is_already_applied` for how that is decided
+    (SQLSTATE on PostgreSQL, message text on SQLite). Idempotency for a missing
+    column is narrowed to RENAME COLUMN, so a missing column on ADD COLUMN or
+    CREATE INDEX — which would indicate schema corruption, not a re-run — is never
+    silently swallowed.
     Any other error is logged and re-raised — callers must not assume silent
     recovery, as a failure will abort the migration sequence and prevent
     application startup.
@@ -586,14 +671,7 @@ async def _safe_execute(conn, sql):
         async with conn.begin_nested():
             await conn.execute(text(sql))
     except (OperationalError, ProgrammingError) as exc:
-        msg = str(exc).lower()
-        # Only swallow "column … does not exist" for RENAME COLUMN — not for ADD COLUMN
-        # or CREATE INDEX where it would indicate schema corruption, not idempotency.
-        column_not_exists = "rename column" in sql.lower() and "column" in msg and "does not exist" in msg
-        if (
-            not any(k in msg for k in ("already exists", "duplicate key", "duplicate column name", "no such column"))
-            and not column_not_exists
-        ):
+        if not _is_already_applied(exc, sql):
             logger.error("Migration statement failed: %s | SQL: %.200s", exc, sql)
             raise
 
@@ -809,6 +887,168 @@ async def _migrate_scope_run_filament_to_plate(conn) -> None:
         )
 
 
+async def _reclassify_sliced_3mf_library_files(conn) -> None:
+    """Re-type library rows holding a sliced 3MF that does not say so (#2993).
+
+    ``file_type`` was decided from the filename alone, so a sliced 3MF whose
+    name lacks the ``.gcode`` infix landed as a source-only project. That is
+    not a rare shape: a plate exported from Studio, or a print dispatched
+    through the cloud, reaches the archive as ``Foo.3mf`` with its G-code
+    intact, and downloading one and re-importing it produced a library file
+    Bambuddy refused to offer a Print button for. The forward fix classifies on
+    content; this pass reaches the rows already stored.
+
+    One-shot, for the same reason the #2614 backfill is: a genuine source 3MF
+    keeps matching ``file_type = '3mf'`` forever, so without the gate every
+    boot would re-open every model file in the library.
+
+    External rows are deliberately skipped. They point at a mount that may be
+    slow, unmounted, or enormous, and startup is the worst possible place to
+    find that out -- the folder's own scan re-types them with no such risk.
+    """
+    from pathlib import Path
+
+    from sqlalchemy import text
+
+    from backend.app.utils.threemf_tools import carries_gcode
+
+    flag = "_backfill_2993_sliced_3mf_type_done"
+
+    async with conn.begin_nested():
+        already = (
+            await conn.execute(text('SELECT value FROM settings WHERE "key" = :k'), {"k": flag})
+        ).scalar_one_or_none()
+        if already:
+            return
+
+        rows = (
+            await conn.execute(
+                text(
+                    "SELECT id, file_path FROM library_files "
+                    "WHERE file_type = '3mf' AND deleted_at IS NULL "
+                    "AND file_path IS NOT NULL AND file_path <> '' "
+                    "AND (is_external IS NULL OR is_external = :false_val)"
+                ),
+                {"false_val": False},
+            )
+        ).fetchall()
+
+        reclassified = 0
+        for row in rows:
+            path = Path(row.file_path)
+            if not path.is_absolute():
+                path = settings.base_dir / row.file_path
+            # carries_gcode swallows a missing or unreadable file, so a library
+            # with holes in it still finishes the pass.
+            if not carries_gcode(path):
+                continue
+            await conn.execute(
+                text("UPDATE library_files SET file_type = 'gcode.3mf' WHERE id = :id"),
+                {"id": row.id},
+            )
+            reclassified += 1
+
+        if reclassified:
+            logger.info(
+                "[#2993] Re-typed %d library file(s) from source 3MF to sliced -- they carry G-code",
+                reclassified,
+            )
+
+        # Marked done even when nothing matched, so the scan never repeats.
+        await conn.execute(
+            text('INSERT INTO settings ("key", value) VALUES (:k, :v)'),
+            {"k": flag, "v": "true"},
+        )
+
+
+async def _backfill_archive_bed_temperature(conn) -> None:
+    """Fill in ``print_archives.bed_temperature`` for archives written before #2989.
+
+    Bed temperature was read by looking for a ``bed_temperature`` key, which
+    BambuStudio does not write -- it stores a per-filament array per plate type
+    and names the fitted plate in ``curr_bed_type``. Every archive from a Bambu
+    slice therefore stored NULL: 0 of 455 real 3MFs resolved on the install this
+    was measured on. The forward fix reads the right array; without this, every
+    archive made before it stays blank, and preheat keeps falling back to the
+    keep-warm bed temperature when those jobs are reprinted from the queue.
+
+    Only rows that are still NULL are touched, and only from the 3MF already on
+    disk -- nothing is invented and nothing already recorded is overwritten. An
+    archive whose file is gone (a no-3MF fallback, or one whose 3MF has been
+    cleaned up) is skipped and stays NULL, which is the honest answer.
+
+    Gated to run exactly once via a settings flag, like #2614's repair. The
+    work itself is repeatable -- it only fills NULLs -- but the rows it cannot
+    fill are exactly the ones it would re-open on every boot, and that set grows
+    with print history.
+    """
+    from pathlib import Path
+
+    from sqlalchemy import text
+
+    from backend.app.utils.threemf_tools import extract_bed_temperature_from_3mf
+
+    flag = "_backfill_2989_bed_temperature_done"
+
+    async with conn.begin_nested():
+        already = (
+            await conn.execute(text('SELECT value FROM settings WHERE "key" = :k'), {"k": flag})
+        ).scalar_one_or_none()
+        if already is not None:
+            # Presence, not truthiness. A flag row that somehow holds an empty
+            # string would otherwise re-run and then fail the unique key on the
+            # INSERT below -- which, at startup, is a boot loop.
+            return
+
+        rows = (
+            await conn.execute(
+                text(
+                    "SELECT id, file_path FROM print_archives "
+                    "WHERE bed_temperature IS NULL "
+                    "AND file_path IS NOT NULL AND file_path != ''"
+                )
+            )
+        ).fetchall()
+
+        filled = 0
+        for row in rows:
+            # Per row, and broad, for the reason in the extractor's docstring:
+            # nothing above this has a handler, so one unreadable archive must
+            # not cost the user their boot. #2614's repair guards its rows the
+            # same way.
+            try:
+                path = Path(row.file_path)
+                if not path.is_absolute():
+                    path = settings.base_dir / row.file_path
+                if not path.exists():
+                    continue
+                temperature = extract_bed_temperature_from_3mf(path)
+            except Exception as exc:
+                logger.warning("[#2989] could not read %s for archive %s: %s", row.file_path, row.id, exc)
+                continue
+            if not temperature:
+                continue
+            await conn.execute(
+                text("UPDATE print_archives SET bed_temperature = :t WHERE id = :id"),
+                {"t": temperature, "id": row.id},
+            )
+            filled += 1
+
+        if filled:
+            logger.info(
+                "[#2989] Read the bed temperature from the 3MF for %d archive(s) that had none",
+                filled,
+            )
+
+        # Marked done even when nothing matched, so the rows it could not fill --
+        # which are exactly the ones it would re-open every boot -- are not
+        # rescanned forever. Same shape as #2614's one-shot.
+        await conn.execute(
+            text('INSERT INTO settings ("key", value) VALUES (:k, :v)'),
+            {"k": flag, "v": "true"},
+        )
+
+
 async def _migrate_drop_library_print_name(conn) -> None:
     """Strip the embedded 3MF Title (``print_name``) from library file metadata (#1489).
 
@@ -1239,6 +1479,255 @@ async def _migrate_add_print_archive_cost_center(conn) -> None:
     )
 
 
+# Historical failure-reason labels, mapped to the canonical key that replaced
+# them (issue #2974).
+#
+# Three writers used to put three different spellings of one cause into
+# ``failure_reason``: the backend wrote English display labels, older versions
+# of the archive editor wrote the *translated* label in whatever locale that
+# user was running, and two stale-archive paths wrote English prose sentences.
+# The Failure Analysis widget groups on the raw column, so one real cause could
+# occupy several buckets -- measured on a live install before this landed: 91
+# rows reading "User cancelled" beside 1 reading "userCancelled", which in an
+# English UI rendered as the same words twice with different counts.
+#
+# This is deliberately a FROZEN SNAPSHOT rather than something derived from the
+# locale files at run time. It maps values as they were written historically; if
+# a translation is reworded tomorrow, the old string is still what sits in the
+# database and still has to map. Regenerating it from ``en.ts`` and friends
+# would silently stop recognising the very rows it exists to convert.
+#
+# Every label here resolves to exactly one key -- verified across all 14 locales
+# with no collisions -- so the conversion is exact rather than a best guess. A
+# value that is NOT in this map (free text from an older build, a translation
+# since edited) is deliberately left alone: it already renders through the
+# ``defaultValue`` fallback in both the editor and the Statistics breakdown, and
+# guessing at it would be worse than leaving one honest string in its own bucket.
+_LEGACY_FAILURE_REASON_LABELS: dict[str, str] = {
+    "Adhesion failure": "adhesionFailure",
+    "Agotamiento del filamento": "filamentRunout",
+    "Alabeo": "warping",
+    "Altro": "other",
+    "Annullato dall'utente": "userCancelled",
+    "Annulé par l'utilisateur": "userCancelled",
+    "Aucune mise à jour d'état reçue": "noStatusUpdate",
+    "Autre": "other",
+    "Az ekstrüzyon": "underExtrusion",
+    "Bico entupido": "cloggedNozzle",
+    "Boquilla obstruida": "cloggedNozzle",
+    "Buse bouchée": "cloggedNozzle",
+    "Bükülme": "warping",
+    "Cancelada por el usuario": "userCancelled",
+    "Cancelado pelo usuário": "userCancelled",
+    "Clogged nozzle": "cloggedNozzle",
+    "Corte de corriente": "powerFailure",
+    "Coupure courant": "powerFailure",
+    "Deformazione": "warping",
+    "Deslocamento de camada": "layerShift",
+    "Desplazamiento de capa": "layerShift",
+    "Diğer": "other",
+    "Door gebruiker geannuleerd": "userCancelled",
+    "Draadvorming": "stringing",
+    "Durum güncellemesi alınmadı": "noStatusUpdate",
+    "Décalage de couche": "layerShift",
+    "Défaut d'adhésion": "adhesionFailure",
+    "Empenamento": "warping",
+    "Espagueti / Desprendido": "spaghettiDetached",
+    "Fadenziehen": "stringing",
+    "Falha de adesão": "adhesionFailure",
+    "Falha de energia": "powerFailure",
+    "Fallimento adesione": "adhesionFailure",
+    "Fallo de adhesión": "adhesionFailure",
+    "Filament aufgebraucht": "filamentRunout",
+    "Filament bitti": "filamentRunout",
+    "Filament fini": "filamentRunout",
+    "Filament op": "filamentRunout",
+    "Filament runout": "filamentRunout",
+    "Filamento": "stringing",
+    "Filamento esaurito": "filamentRunout",
+    "Fim do filamento": "filamentRunout",
+    "Fios": "stringing",
+    "Geen statusupdate ontvangen": "noStatusUpdate",
+    "Güç kesintisi": "powerFailure",
+    "Haftungsfehler": "adhesionFailure",
+    "Hechtingsprobleem": "adhesionFailure",
+    "Hilos": "stringing",
+    "Katman kayması": "layerShift",
+    "Kein Statusupdate empfangen": "noStatusUpdate",
+    "Kromtrekken": "warping",
+    "Kullanıcı iptal etti": "userCancelled",
+    "Laagverschuiving": "layerShift",
+    "Layer shift": "layerShift",
+    "Mancanza corrente": "powerFailure",
+    "Nenhuma atualização de status recebida": "noStatusUpdate",
+    "Nessun aggiornamento di stato ricevuto": "noStatusUpdate",
+    "No se recibió actualización de estado": "noStatusUpdate",
+    "No status update received": "noStatusUpdate",
+    "Onderextrusie": "underExtrusion",
+    "Other": "other",
+    "Otro": "other",
+    "Outro": "other",
+    "Overig": "other",
+    "Power failure": "powerFailure",
+    "Schichtversatz": "layerShift",
+    "Sonstiges": "other",
+    "Sotto-estrusione": "underExtrusion",
+    "Sous-extrusion": "underExtrusion",
+    "Spagetti / Ayrılmış": "spaghettiDetached",
+    "Spaghetti / Abgelöst": "spaghettiDetached",
+    "Spaghetti / Destacado": "spaghettiDetached",
+    "Spaghetti / Detached": "spaghettiDetached",
+    "Spaghetti / Détaché": "spaghettiDetached",
+    "Spaghetti / losgeraakt": "spaghettiDetached",
+    "Spaghetti / staccato": "spaghettiDetached",
+    "Spostamento layer": "layerShift",
+    "Stale - print likely cancelled or failed without status update": "noStatusUpdate",
+    "Stale - reconciled after reconnect, end time unknown": "noStatusUpdate",
+    "Stringing": "stringing",
+    "Stringing (Cheveux d'ange)": "stringing",
+    "Stromausfall": "powerFailure",
+    "Stroomuitval": "powerFailure",
+    "Subextrusión": "underExtrusion",
+    "Subextrusão": "underExtrusion",
+    "Tıkalı nozul": "cloggedNozzle",
+    "Ugello intasato": "cloggedNozzle",
+    "Under-extrusion": "underExtrusion",
+    "Unterextrusion": "underExtrusion",
+    "User cancelled": "userCancelled",
+    "Verformung": "warping",
+    "Verstopfte Düse": "cloggedNozzle",
+    "Verstopte nozzle": "cloggedNozzle",
+    "Vom Benutzer abgebrochen": "userCancelled",
+    "Warping": "warping",
+    "Warping (Déformation)": "warping",
+    "Yapışma başarısız": "adhesionFailure",
+    "İplik oluşumu": "stringing",
+    "Биття філаменту": "filamentRunout",
+    "Викривлення": "warping",
+    "Другое": "other",
+    "Закончился филамент": "filamentRunout",
+    "Засмічене сопло": "cloggedNozzle",
+    "Засор сопла": "cloggedNozzle",
+    "Збій живлення": "powerFailure",
+    "Зсув шару": "layerShift",
+    "Користувач скасовано": "userCancelled",
+    "Коробление": "warping",
+    "Нанизування": "stringing",
+    "Недоэкструзия": "underExtrusion",
+    "Обновление статуса не получено": "noStatusUpdate",
+    "Оновлення статусу не отримано": "noStatusUpdate",
+    "Отменено пользователем": "userCancelled",
+    "Плохая адгезия к столу": "adhesionFailure",
+    "Порушення адгезії": "adhesionFailure",
+    "Підвидавлювання": "underExtrusion",
+    "Сбой питания": "powerFailure",
+    "Сдвиг слоёв": "layerShift",
+    "Спагетти / отрыв детали": "spaghettiDetached",
+    "Спагетті / Відр": "spaghettiDetached",
+    "Стрингинг": "stringing",
+    "інше": "other",
+    "その他": "other",
+    "ステータス更新を受信できませんでした": "noStatusUpdate",
+    "スパゲッティ / 剥離": "spaghettiDetached",
+    "ノズル詰まり": "cloggedNozzle",
+    "フィラメント切れ": "filamentRunout",
+    "ユーザーによるキャンセル": "userCancelled",
+    "レイヤーシフト": "layerShift",
+    "使用者取消": "userCancelled",
+    "其他": "other",
+    "反り": "warping",
+    "喷嘴堵塞": "cloggedNozzle",
+    "噴嘴堵塞": "cloggedNozzle",
+    "定着不良": "adhesionFailure",
+    "层偏移": "layerShift",
+    "層偏移": "layerShift",
+    "押出不足": "underExtrusion",
+    "拉丝": "stringing",
+    "拉丝 / 脱落": "spaghettiDetached",
+    "拉絲": "stringing",
+    "拉絲 / 脫落": "spaghettiDetached",
+    "挤出不足": "underExtrusion",
+    "擠出不足": "underExtrusion",
+    "断电": "powerFailure",
+    "斷電": "powerFailure",
+    "未收到状态更新": "noStatusUpdate",
+    "未收到狀態更新": "noStatusUpdate",
+    "用户取消": "userCancelled",
+    "糸引き": "stringing",
+    "翘曲": "warping",
+    "翹曲": "warping",
+    "耗材用完": "filamentRunout",
+    "附着力失败": "adhesionFailure",
+    "附著力失敗": "adhesionFailure",
+    "電源障害": "powerFailure",
+    "기타": "other",
+    "노즐 막힘": "cloggedNozzle",
+    "레이어 시프트": "layerShift",
+    "사용자 취소": "userCancelled",
+    "상태 업데이트를 받지 못함": "noStatusUpdate",
+    "스트링": "stringing",
+    "스파게티 / 분리": "spaghettiDetached",
+    "압출 부족": "underExtrusion",
+    "전원 실패": "powerFailure",
+    "접착 실패": "adhesionFailure",
+    "필라멘트 소진": "filamentRunout",
+    "휨": "warping",
+}
+
+
+async def _migrate_failure_reason_vocabulary(conn):
+    """Fold historical failure-reason labels onto the canonical keys (#2974).
+
+    ``print_archives.failure_reason`` and ``print_log_entries.failure_reason``
+    accumulated three spellings of the same cause -- see
+    ``_LEGACY_FAILURE_REASON_LABELS`` for who wrote what. The PATCH route in
+    ``api/routes/print_log.py`` has enforced the key vocabulary for a while and
+    ``derive_failure_reason`` now produces it too, so this is the one-time pass
+    that brings existing rows in line.
+
+    Deliberately NOT gated behind a settings flag, unlike the #2614 backfill.
+    The statement is self-terminating -- it only matches values in the map, and
+    a key is never a label, so a second run updates nothing -- which makes the
+    flag pure overhead. It would also be actively wrong: a user who restores an
+    older database, or upgrades through this version twice, would carry the flag
+    with none of the conversion, and their legacy rows would never be touched
+    again. Cheap and repeatable beats one-shot here.
+    """
+    from collections import defaultdict
+
+    from sqlalchemy import bindparam, text
+
+    # Invert the map before issuing anything: 168 labels collapse onto 12 keys,
+    # so one UPDATE per key with an IN list is 24 statements rather than 336
+    # single-value ones on every boot. Identity rows (an en.ts label that is
+    # spelled the same as its own key) are dropped -- they would match and
+    # rewrite themselves to the value they already hold.
+    by_key: dict[str, list[str]] = defaultdict(list)
+    for label, key in _LEGACY_FAILURE_REASON_LABELS.items():
+        if label != key:
+            by_key[key].append(label)
+
+    total = 0
+    async with conn.begin_nested():
+        # nosec B608 — the only interpolated fragment is `table`, which the loop
+        # below draws from a literal tuple; no caller value reaches the string.
+        # Both the key and the label list are bound parameters. A table name
+        # cannot be expressed as one, which is why it is interpolated at all.
+        for table in ("print_archives", "print_log_entries"):
+            for key, labels in by_key.items():
+                result = await conn.execute(
+                    text(
+                        f"UPDATE {table} SET failure_reason = :key "  # noqa: S608  # nosec B608
+                        "WHERE failure_reason IN :labels"
+                    ).bindparams(bindparam("key"), bindparam("labels", expanding=True)),
+                    {"key": key, "labels": labels},
+                )
+                total += result.rowcount or 0
+
+    if total:
+        logger.info("[#2974] converted %d failure_reason value(s) to the canonical vocabulary", total)
+
+
 async def run_migrations(conn):
     """Run all schema migrations and data backfills on startup.
 
@@ -1258,6 +1747,11 @@ async def run_migrations(conn):
     # them. Fresh installs remain idempotent because create_all() runs first.
     await _migrate_create_finance_tables(conn)
 
+    # Data migration: one vocabulary for failure_reason (#2974). Runs early so
+    # the Failure Analysis widget and the archive editor never observe a
+    # half-converted column.
+    await _migrate_failure_reason_vocabulary(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.
@@ -1268,8 +1762,8 @@ async def run_migrations(conn):
 
     # Migration: Add source_archive_id column to pipeline_runs (#1425 PR B follow-up).
     # Allows a pipeline run to source from an archive's source 3MF in addition
-    # to a library file. Idempotent — _safe_execute swallows the "already exists"
-    # case on both SQLite and Postgres.
+    # to a library file. Idempotent — _safe_execute swallows an already-applied
+    # statement on both SQLite and Postgres.
     await _safe_execute(
         conn,
         "ALTER TABLE pipeline_runs ADD COLUMN source_archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL",
@@ -2030,7 +2524,7 @@ async def run_migrations(conn):
     # timestamp; the type differs by dialect (SQLite DATETIME vs Postgres
     # TIMESTAMP) so an existing-DB upgrade doesn't hit "type datetime does not
     # exist" on Postgres. On a fresh DB create_all() already built the column, so
-    # the ALTER is swallowed as "already exists".
+    # the ALTER is swallowed as already applied.
     #
     # Placed AFTER the print_queue_new2 table-recreate above: that recreate
     # (SQLite-only, and only on ancient DBs whose archive_id is still NOT NULL)
@@ -3224,17 +3718,17 @@ async def run_migrations(conn):
     # SQLite does not support ALTER TABLE ADD CONSTRAINT — handled by __table_args__ at creation.
     # Runs AFTER the backfill so Fall B rows don't fail constraint validation.
     if not is_sqlite():
+        add_constraint = (
+            "ALTER TABLE oidc_providers ADD CONSTRAINT ck_auto_link_requires_verified_email_claim "
+            "CHECK (auto_link_existing_accounts = FALSE OR email_claim != 'email' OR require_email_verified = TRUE)"
+        )
         try:
             async with conn.begin_nested():
-                await conn.execute(
-                    text(
-                        "ALTER TABLE oidc_providers ADD CONSTRAINT ck_auto_link_requires_verified_email_claim "
-                        "CHECK (auto_link_existing_accounts = FALSE OR email_claim != 'email' OR require_email_verified = TRUE)"
-                    )
-                )
+                await conn.execute(text(add_constraint))
         except (OperationalError, ProgrammingError) as exc:
-            msg = str(exc).lower()
-            if "already exists" not in msg:
+            # Classified by SQLSTATE, not by message text: a non-English server
+            # reports the constraint as already present in its own language (#2949).
+            if not _is_already_applied(exc, add_constraint):
                 logger.error(
                     "Security constraint migration FAILED — auto_link safety constraint may not be enforced: %s",
                     exc,
@@ -3862,6 +4356,34 @@ async def run_migrations(conn):
             conn, "ALTER TABLE notification_providers ADD COLUMN on_stock_break_alert BOOLEAN DEFAULT false"
         )
 
+    # Backfill the two flags above. The DEFAULT on those ALTERs only reaches
+    # existing rows when the ALTER is the statement that adds the column -- and
+    # on an install whose notification_providers table was (re)created from
+    # Base.metadata, create_all() had already added them by the time migrations
+    # ran, so _safe_execute swallowed the ALTER as a duplicate column and every
+    # pre-existing row kept NULL. Harmless while nothing read the flags; a 500
+    # on the whole provider list once #2827 declared them on the response
+    # schema, because pydantic will not accept None for a bool.
+    #
+    # false matches both the intent of the DEFAULT above and the behaviour the
+    # rows already have: _get_providers_for_event filters on `.is_(True)`, so a
+    # NULL flag never sent anything. Idempotent -- the WHERE matches nothing on
+    # the second run.
+    async with conn.begin_nested():
+        stock_backfill = await conn.execute(
+            text(
+                "UPDATE notification_providers SET on_stock_reorder_alert = :off WHERE on_stock_reorder_alert IS NULL"
+            ),
+            {"off": False},
+        )
+        stock_backfill_break = await conn.execute(
+            text("UPDATE notification_providers SET on_stock_break_alert = :off WHERE on_stock_break_alert IS NULL"),
+            {"off": False},
+        )
+    repaired = (stock_backfill.rowcount or 0) + (stock_backfill_break.rowcount or 0)
+    if repaired:
+        logger.info("Backfilled %s NULL inventory stock alert flag(s) on notification_providers", repaired)
+
     # Migration: Heal orphan auth-related rows left behind by user-delete
     # on SQLite. user_oidc_links, user_totp, user_otp_codes (introduced in
     # PR #933) and long_lived_tokens (PR #1108) all declare ON DELETE
@@ -4280,6 +4802,16 @@ async def run_migrations(conn):
     # #2603 archive plate_id backfill above so print_archives.plate_id is populated.
     await _migrate_scope_run_filament_to_plate(conn)
 
+    # Backfill: archives written before #2989 have no bed temperature, because
+    # the extractor looked for a key BambuStudio never writes. Re-reads the 3MF
+    # already on disk. One-shot; see the function for why it is gated.
+    await _backfill_archive_bed_temperature(conn)
+
+    # Backfill: library rows typed from the filename alone kept a sliced 3MF
+    # named `Foo.3mf` filed as a source-only project (#2993). Re-reads the zip
+    # already on disk. One-shot, internal rows only; see the function.
+    await _reclassify_sliced_3mf_library_files(conn)
+
     # Migration: Add controls_printer_power to smart_plugs (#2629). Marks
     # whether a plug actually feeds the printer's own power — only then may an
     # auto-off mark the printer offline. Defaults to true so existing plugs
@@ -4301,7 +4833,7 @@ async def run_migrations(conn):
     # ordering was arbitrary. Nullable; the timestamp type differs by dialect
     # (SQLite DATETIME vs Postgres TIMESTAMP) so an existing-DB upgrade doesn't hit
     # "type datetime does not exist" on Postgres. On a fresh DB create_all() already
-    # built the column, so the ALTER is swallowed as "already exists".
+    # built the column, so the ALTER is swallowed as already applied.
     if is_sqlite():
         await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN fs_modified_at DATETIME")
         await _safe_execute(conn, "ALTER TABLE library_folders ADD COLUMN fs_modified_at DATETIME")
@@ -4392,6 +4924,278 @@ async def run_migrations(conn):
         conn, "ALTER TABLE notification_providers ADD COLUMN on_ams_drying_suspended BOOLEAN DEFAULT TRUE"
     )
 
+    # Migration: storage location sensor alerts (#2824), own column rather than
+    # reusing on_ha_sensor_alert. That column can be scoped to one printer
+    # (printer_id), and a location alert has no printer to scope by — sharing
+    # the column meant a provider narrowed to one printer's sensors silently
+    # also received every drybox alert, with no toggle to separate the two.
+    await _safe_execute(
+        conn, "ALTER TABLE notification_providers ADD COLUMN on_location_ha_sensor_alert BOOLEAN DEFAULT FALSE"
+    )
+
+    # Migration: rename the ha_sensor_alert template (#2824). "Home Assistant
+    # Sensor Alert" was fine as a name while it was the only such template;
+    # next to the new "Storage Location Sensor Alert" it no longer says which
+    # one is the printer's. See _migrate_rename_user_print_template_names for
+    # why this is a plain UPDATE guarded on the old name rather than a
+    # DEFAULT_TEMPLATES re-seed.
+    await _migrate_rename_ha_sensor_alert_template(conn)
+
+    # Migration: back the one-binding-per-(location, entity) rule with a unique
+    # index (#2824). The API's duplicate check is read-then-insert, so two
+    # concurrent creates could both pass it; the index turns the loser into an
+    # IntegrityError the route maps back to the same 400. create_all() adds it
+    # on fresh installs only — this covers databases whose table predates it.
+    await _migrate_location_ha_sensor_unique_binding(conn)
+
+    # Migration: repair the tare of spools the RFID auto-add gave the wrong
+    # Bambu spool row (#2909). Runs last so the spool catalogue it reads is
+    # whatever this database actually holds.
+    await _migrate_repair_rfid_core_weight(conn)
+
+    # Migration: drop the AMS slot markers an older Bambuddy wrote into
+    # Spoolman and the location sync then imported as storage locations.
+    await _migrate_drop_ams_slot_locations(conn)
+
+
+async def _migrate_rename_ha_sensor_alert_template(conn) -> None:
+    """Rename the ha_sensor_alert template to "Printer Sensor Alert" (#2824).
+
+    Renames only if ``name`` is still the old default — an admin who renamed
+    the template themselves keeps their custom name.
+    """
+    from sqlalchemy import text
+
+    await conn.execute(
+        text("UPDATE notification_templates SET name = :new WHERE event_type = :et AND name = :old"),
+        {"new": "Printer Sensor Alert", "et": "ha_sensor_alert", "old": "Home Assistant Sensor Alert"},
+    )
+
+
+async def _migrate_location_ha_sensor_unique_binding(conn) -> None:
+    """Unique index on location_ha_sensors (location_id, entity_id) (#2824).
+
+    Same name and shape as the Index in the model, so fresh installs (which
+    get it from create_all) and upgraded ones end up identical.
+
+    Rows that already violate it — duplicates slipped in through the pre-index
+    race — are collapsed to the oldest row first, because CREATE UNIQUE INDEX
+    refuses to build over duplicates and _safe_execute would re-raise that,
+    aborting startup. The oldest row wins: it is the one the card and the
+    poller cache were already keyed on.
+    """
+    from sqlalchemy import text
+
+    async with conn.begin_nested():
+        await conn.execute(
+            text(
+                "DELETE FROM location_ha_sensors WHERE id NOT IN ("
+                "SELECT MIN(id) FROM location_ha_sensors GROUP BY location_id, entity_id)"
+            )
+        )
+    await _safe_execute(
+        conn,
+        "CREATE UNIQUE INDEX IF NOT EXISTS uq_location_ha_sensors_location_entity "
+        "ON location_ha_sensors (location_id, entity_id)",
+    )
+
+
+async def _migrate_drop_ams_slot_locations(conn) -> None:
+    """Remove imported AMS slot markers from the storage-location catalogue.
+
+    Bambuddy used to record which slot a spool was loaded into by writing
+    "<printer> - AMS A1" into Spoolman's ``location`` field. That writer went
+    away when Storage Location became something the user picks (#1114), but the
+    strings stayed on people's Spoolman spools, and
+    ``sync_locations_from_spoolman`` imported every distinct one -- so a printer
+    slot turned up in the Storage Location dropdown as somewhere to put a spool
+    away. Worse, they could not be cleared by hand: the delete route refuses a
+    location that has spools, and in Spoolman mode it counts them by matching
+    that same string, so every marker still on a loaded spool answered 409.
+
+    The import now skips them (``is_ams_slot_location``); this clears the ones
+    already in the catalogue. A row is only deleted when no spool in this
+    database points at it -- neither by ``location_id`` nor by a legacy
+    free-text ``storage_location`` -- so an internal-mode user who has
+    deliberately filed spools under such a name keeps it, dropdown entry and
+    all.
+
+    Spools in Spoolman are not consulted and not touched: their ``location``
+    strings are the user's data on the user's server, and one that still reads
+    "H2D-1 - AMS A1" in the inventory list is telling the truth about what
+    Spoolman holds. It simply stops being offered as a destination, which is
+    the whole point -- those are exactly the markers this cleans up.
+    """
+    from sqlalchemy import text
+
+    from backend.app.services.location_service import is_ams_slot_location, location_name_key
+
+    flag = "_cleanup_ams_slot_locations_done"
+
+    async with conn.begin_nested():
+        already = (
+            await conn.execute(text('SELECT value FROM settings WHERE "key" = :k'), {"k": flag})
+        ).scalar_one_or_none()
+        if already:
+            return
+
+        rows = (await conn.execute(text("SELECT id, name FROM locations"))).fetchall()
+        removed = []
+        for row in rows:
+            if not is_ams_slot_location(row.name):
+                continue
+            in_use = (
+                await conn.execute(
+                    text(
+                        "SELECT COUNT(*) FROM spool WHERE location_id = :id "
+                        "OR LOWER(TRIM(COALESCE(storage_location, ''))) = :key"
+                    ),
+                    {"id": row.id, "key": location_name_key(row.name)},
+                )
+            ).scalar_one()
+            if in_use:
+                continue
+            await conn.execute(text("DELETE FROM locations WHERE id = :id"), {"id": row.id})
+            removed.append(row.name)
+
+        if removed:
+            logger.info(
+                "Removed %d AMS slot marker(s) from the storage-location catalogue: %s",
+                len(removed),
+                ", ".join(sorted(removed)),
+            )
+
+        await conn.execute(
+            text('INSERT INTO settings ("key", value) VALUES (:k, :v)'),
+            {"k": flag, "v": "true"},
+        )
+
+
+async def _migrate_repair_rfid_core_weight(conn) -> None:
+    """Correct the tare of RFID-added spools that took the wrong catalogue row (#2909).
+
+    A Bambu roll arrives on the 250 g Low Temp spool, but the lookup that gave
+    an auto-added spool its ``core_weight`` asked for the first row whose name
+    starts "Bambu Lab" and took whatever came back. There are three, and which
+    one is first is up to the database: SQLite returns insertion order in
+    practice, Postgres promises nothing once the table has seen an update. So
+    the same roll could be recorded with a 216 g High Temp tare on one install
+    and correctly on another, and the reporting instance here got 216.
+
+    The tare is not cosmetic. A spool weighed on SpoolBuddy has its remaining
+    filament worked out as ``scale reading - core_weight``, so a 34 g low tare
+    credits the roll with 34 g of filament that is not there and writes a
+    ``weight_used`` 34 g short. That error is a constant: every later print
+    adds to ``weight_used`` on top of it, so adding the difference back is an
+    exact repair however much has been printed since. Only rows that have
+    actually been weighed carry it -- a spool that was never on the scale has
+    a ``weight_used`` derived from the AMS remaining percentage, which the tare
+    never touched.
+
+    Which rows: ``data_origin = 'rfid_auto'`` narrows it to spools this code
+    path created, and a ``core_weight`` matching one of the *other* Bambu
+    catalogue rows is the signature of the broken lookup. Reading the weights
+    out of the catalogue rather than hardcoding 216 and 253 keeps it correct on
+    an install whose catalogue has been edited.
+
+    One case cannot be told apart and is stated rather than hidden: a user who
+    moved an RFID roll onto a genuine High Temp spool and set its tare to 216
+    by hand looks identical to a row the lookup got wrong, and is normalised
+    with them. Keying on ``core_weight_catalog_id IS NULL`` instead would not
+    have rescued them -- the spool form's weight picker auto-selects the only
+    catalogue row matching the weight and writes its id on the next save, so
+    that column says only whether the form was ever opened.
+
+    Gated to run exactly once via a settings flag, so a user who deliberately
+    sets one of these tares afterwards keeps it.
+    """
+    from sqlalchemy import bindparam, text
+
+    # The same two values the creating path uses, imported rather than repeated:
+    # a repair that looked for a different row than the code writes would leave
+    # the tare it was built to correct in place. Imported inside the function to
+    # keep this module free of a service-layer dependency at import time.
+    from backend.app.services.spool_tag_matcher import (
+        BAMBU_PLASTIC_SPOOL_CATALOG_NAME,
+        BAMBU_PLASTIC_SPOOL_CORE_WEIGHT,
+    )
+
+    flag = "_backfill_2909_rfid_core_weight_done"
+
+    async with conn.begin_nested():
+        already = (
+            await conn.execute(text('SELECT value FROM settings WHERE "key" = :k'), {"k": flag})
+        ).scalar_one_or_none()
+        if already:
+            return
+
+        # The row that names the spool an RFID roll actually arrives on. Its
+        # absence is not an error -- a catalogue the user has pruned still gets
+        # the documented default.
+        correct = (
+            await conn.execute(
+                text("SELECT id, weight FROM spool_catalog WHERE UPPER(name) = :name ORDER BY id LIMIT 1"),
+                {"name": BAMBU_PLASTIC_SPOOL_CATALOG_NAME.upper()},
+            )
+        ).fetchone()
+        correct_id = correct[0] if correct else None
+        correct_weight = correct[1] if correct else BAMBU_PLASTIC_SPOOL_CORE_WEIGHT
+
+        bambu_weights = {
+            row[0]
+            for row in (
+                await conn.execute(
+                    text("SELECT weight FROM spool_catalog WHERE UPPER(name) LIKE :prefix"),
+                    {"prefix": "BAMBU LAB%"},
+                )
+            ).fetchall()
+        }
+        wrong_weights = sorted(bambu_weights - {correct_weight})
+
+        repaired = 0
+        reweighed = 0
+        if wrong_weights:
+            rows = (
+                await conn.execute(
+                    text(
+                        "SELECT id, core_weight, label_weight, weight_used, last_weighed_at FROM spool "
+                        "WHERE data_origin = 'rfid_auto' AND core_weight IN :wrong"
+                    ).bindparams(bindparam("wrong", expanding=True)),
+                    {"wrong": wrong_weights},
+                )
+            ).fetchall()
+
+            for row in rows:
+                delta = correct_weight - row.core_weight
+                weight_used = row.weight_used or 0.0
+                if row.last_weighed_at is not None:
+                    weight_used = min(max(0.0, weight_used + delta), float(row.label_weight or 0))
+                    reweighed += 1
+                await conn.execute(
+                    text(
+                        "UPDATE spool SET core_weight = :cw, core_weight_catalog_id = :cid, "
+                        "weight_used = :wu WHERE id = :id"
+                    ),
+                    {"cw": correct_weight, "cid": correct_id, "wu": weight_used, "id": row.id},
+                )
+                repaired += 1
+
+        if repaired:
+            logger.info(
+                "[#2909] Corrected the spool tare on %d RFID-added spool(s) to %d g; "
+                "%d of them had been weighed and had their used weight adjusted with it",
+                repaired,
+                correct_weight,
+                reweighed,
+            )
+
+        # Marked done even when nothing matched, so the one-shot never reopens
+        # a tare the user has since set for themselves.
+        await conn.execute(
+            text('INSERT INTO settings ("key", value) VALUES (:k, :v)'),
+            {"k": flag, "v": "true"},
+        )
+
 
 async def _migrate_backfill_variant_groups(conn) -> None:
     """Build variant groups from the slice provenance already on disk (#671 / #2570).

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 658 - 105
backend/app/main.py


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

@@ -11,6 +11,7 @@ from backend.app.models.kprofile_note import KProfileNote
 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.location_ha_sensor import LocationHASensor
 from backend.app.models.long_lived_token import LongLivedToken
 from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
 from backend.app.models.notification import NotificationLog
@@ -24,6 +25,7 @@ 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.scheduled_drying import ScheduledDrying
 from backend.app.models.settings import Settings
 from backend.app.models.slicer_pipeline import SlicerPipeline
 from backend.app.models.smart_plug import SmartPlug
@@ -32,6 +34,7 @@ from backend.app.models.sponsor_toast_state import SponsorToastState
 from backend.app.models.spool import Spool
 from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.models.spool_catalog import SpoolCatalogEntry
+from backend.app.models.spool_filament_preset import SpoolFilamentPreset, SpoolmanFilamentPreset
 from backend.app.models.spool_k_profile import SpoolKProfile
 from backend.app.models.spool_usage_history import SpoolUsageHistory
 from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
@@ -58,6 +61,7 @@ __all__ = [
     "AMSSensorHistory",
     "PrinterSensorHistory",
     "PrinterHASensor",
+    "ScheduledDrying",
     "AmsLabel",
     "PendingUpload",
     "PrintBatch",
@@ -79,7 +83,9 @@ __all__ = [
     "PipelineRun",
     "SlicerPipeline",
     "Spool",
+    "SpoolFilamentPreset",
     "SpoolKProfile",
+    "SpoolmanFilamentPreset",
     "SpoolAssignment",
     "SpoolCatalogEntry",
     "SpoolUsageHistory",

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

@@ -7,6 +7,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
 from backend.app.core.database import Base
 
 if TYPE_CHECKING:
+    from backend.app.models.location_ha_sensor import LocationHASensor
     from backend.app.models.spool import Spool
 
 
@@ -25,3 +26,4 @@ class Location(Base):
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
 
     spools: Mapped[list["Spool"]] = relationship(back_populates="location")
+    ha_sensors: Mapped[list["LocationHASensor"]] = relationship(back_populates="location", cascade="all, delete-orphan")

+ 76 - 0
backend/app/models/location_ha_sensor.py

@@ -0,0 +1,76 @@
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Index, Integer, String, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from backend.app.core.database import Base
+
+# Width of the last_state column. The poller truncates what it persists to
+# this, because a numeric entity can start reporting free text (an enum, an
+# error string) longer than the column — SQLite stores it anyway, but
+# PostgreSQL rejects the row and takes the whole poll batch's commit with it.
+LAST_STATE_MAX_LENGTH = 64
+
+
+class LocationHASensor(Base):
+    """A read-only Home Assistant entity bound to a storage location (#2824).
+
+    Mirrors ``PrinterHASensor`` for dryboxes, bins and shelves instead of
+    printers — same read-only binding, alert rule and notification, but no
+    print-blocking: holding a print queue doesn't mean anything for a
+    storage bin.
+    """
+
+    __tablename__ = "location_ha_sensors"
+    # The API rejects a duplicate (location, entity) binding, but that check is
+    # read-then-insert — two concurrent creates can both pass it. This index is
+    # the backstop that turns the loser into an IntegrityError instead of a
+    # second row silently shadowing the first. create_all() only covers fresh
+    # installs; upgraded databases get it from
+    # _migrate_location_ha_sensor_unique_binding in core/database.py, which
+    # must create the same index under the same name.
+    __table_args__ = (Index("uq_location_ha_sensors_location_entity", "location_id", "entity_id", unique=True),)
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    location_id: Mapped[int] = mapped_column(ForeignKey("locations.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
+    # category a sensor is treated as (temperature/humidity/battery) and the
+    # unit shown next to the value.
+    device_class: Mapped[str | None] = mapped_column(String(32), nullable=True)
+    # Numeric only: "°C", "%", ... shown next to the value.
+    unit: Mapped[str | None] = mapped_column(String(16), nullable=True)
+
+    # What counts as needing attention. One notion, two consumers: the
+    # colorized value on the card/table and the notification. 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)
+
+    notify_on_alert: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
+
+    show_on_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(LAST_STATE_MAX_LENGTH), 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())
+
+    location: Mapped["Location"] = relationship(back_populates="ha_sensors")
+
+
+from backend.app.models.location import Location  # noqa: E402

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

@@ -89,6 +89,13 @@ class NotificationProvider(Base):
     # 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 - Home Assistant sensors bound to a storage location (#2824)
+    # Its own column rather than reusing on_ha_sensor_alert above: that one can
+    # be scoped to a single printer, and a location alert has no printer to
+    # scope by, so sharing it would leak drybox alerts to a provider narrowed
+    # to one printer's sensors.
+    on_location_ha_sensor_alert = Column(Boolean, default=False)
+
     # 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)

+ 7 - 1
backend/app/models/notification_template.py

@@ -139,10 +139,16 @@ DEFAULT_TEMPLATES = [
     },
     {
         "event_type": "ha_sensor_alert",
-        "name": "Home Assistant Sensor Alert",
+        "name": "Printer Sensor Alert",
         "title_template": "Sensor Alert",
         "body_template": "{printer}: {sensor} is {state}",
     },
+    {
+        "event_type": "location_ha_sensor_alert",
+        "name": "Storage Location Sensor Alert",
+        "title_template": "Sensor Alert",
+        "body_template": "{location}: {sensor} is {state}",
+    },
     {
         "event_type": "first_layer_complete",
         "name": "First Layer Complete",

+ 8 - 1
backend/app/models/printer_ha_sensor.py

@@ -5,6 +5,13 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from backend.app.core.database import Base
 
+# Width of the last_state column. The poller truncates what it persists to
+# this, because a numeric entity can start reporting free text (an enum, an
+# error string) longer than the column -- SQLite stores it anyway, but
+# PostgreSQL rejects the row and takes the whole poll batch's commit with it.
+# Its sibling in models/location_ha_sensor.py says the same for that table.
+LAST_STATE_MAX_LENGTH = 64
+
 
 class PrinterHASensor(Base):
     """A read-only Home Assistant entity bound to a printer (#1148, #448).
@@ -59,7 +66,7 @@ class PrinterHASensor(Base):
 
     # 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_state: Mapped[str | None] = mapped_column(String(LAST_STATE_MAX_LENGTH), nullable=True)
     last_changed: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
     last_checked: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
 

+ 47 - 0
backend/app/models/scheduled_drying.py

@@ -0,0 +1,47 @@
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from backend.app.core.database import Base
+
+
+class ScheduledDrying(Base):
+    """A manual AMS drying run scheduled to start later (#2638).
+
+    Dispatched by PrintScheduler._check_scheduled_dryings() when start_after
+    has passed and the printer is idle. Parameters mirror the immediate
+    POST /printers/{id}/drying/start endpoint.
+    """
+
+    __tablename__ = "scheduled_dryings"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"))
+    ams_id: Mapped[int] = mapped_column(Integer, default=0)
+
+    temp: Mapped[int] = mapped_column(Integer)
+    duration_hours: Mapped[int] = mapped_column(Integer)
+    filament: Mapped[str] = mapped_column(String(50), default="")
+    rotate_tray: Mapped[bool] = mapped_column(Boolean, default=False)
+
+    # Earliest start instant, naive UTC (same convention as
+    # print_queue.scheduled_time). None = start as soon as the printer is idle.
+    start_after: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+
+    # pending / running / completed / cancelled / failed
+    status: Mapped[str] = mapped_column(String(20), default="pending")
+    waiting_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
+    error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
+
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
+
+    printer: Mapped["Printer"] = relationship()
+    created_by: Mapped["User | None"] = relationship()
+
+
+from backend.app.models.printer import Printer  # noqa: E402
+from backend.app.models.user import User  # noqa: E402

+ 8 - 0
backend/app/models/spool.py

@@ -74,10 +74,18 @@ class Spool(Base):
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
 
     k_profiles: Mapped[list["SpoolKProfile"]] = relationship(back_populates="spool", cascade="all, delete-orphan")
+    # Per-printer-model preset overrides. Deliberately NOT embedded in
+    # SpoolResponse the way k_profiles is: the inventory list returns every
+    # spool a user owns, and this list is only ever read by the spool form
+    # and the assign path, both of which fetch it for one spool at a time.
+    filament_presets: Mapped[list["SpoolFilamentPreset"]] = relationship(
+        back_populates="spool", cascade="all, delete-orphan"
+    )
     assignments: Mapped[list["SpoolAssignment"]] = relationship(back_populates="spool", cascade="all, delete-orphan")
     location: Mapped["Location | None"] = relationship(back_populates="spools")
 
 
 from backend.app.models.location import Location  # noqa: E402
 from backend.app.models.spool_assignment import SpoolAssignment  # noqa: E402
+from backend.app.models.spool_filament_preset import SpoolFilamentPreset  # noqa: E402
 from backend.app.models.spool_k_profile import SpoolKProfile  # noqa: E402

+ 90 - 0
backend/app/models/spool_filament_preset.py

@@ -0,0 +1,90 @@
+from datetime import datetime
+
+from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from backend.app.core.database import Base
+
+
+class SpoolFilamentPreset(Base):
+    """Per-printer-model override of a spool's slicer filament preset.
+
+    ``Spool.slicer_filament`` holds ONE preset, and that is deliberate -- the
+    spool form is printer-agnostic and the user picks the variant they want
+    (see ``spool-form/utils.ts``). It stops being enough as soon as the same
+    spool is used on two different printer models: a cloud or Orca preset is
+    bound to a model (``@BBL X1C``), so the spool that carries an X1C variant
+    configures an AMS slot on an H2C with a preset that machine has no profile
+    for.
+
+    Keyed on the printer MODEL, not the printer: ``@BBL X1C`` is the same
+    preset on every X1C the user owns, and keying per machine would make them
+    pick the identical value once per printer. (K profiles are the opposite --
+    a K value is measured on one individual hotend -- which is why
+    ``spool_k_profile`` keys on ``printer_id`` and this does not.)
+
+    ``nozzle_diameter`` is part of the key because the preset lands on an AMS
+    slot, and a slot feeds exactly one nozzle: on a dual-nozzle machine with
+    two different diameters fitted, one preset per model cannot be right for
+    both hotends, and diameter-specific presets genuinely exist
+    (``Bambu PLA Basic @BBL A1M 0.2 nozzle``). Empty string means "any nozzle
+    of this model". The spool form does not write that row -- it offers one row
+    per nozzle size and nothing above them, because a preset lands on an AMS
+    slot and a slot feeds exactly one nozzle -- but the level is kept in the
+    cascade for API clients that want one value to cover a whole model.
+    Resolution order is
+
+        exact (model, diameter) -> (model, "") -> ``Spool.slicer_filament``
+
+    which is what ``services.spool_filament_preset.resolve_spool_preset``
+    implements. Empty string rather than NULL because NULLs compare distinct
+    in a UNIQUE constraint on both SQLite and PostgreSQL, so a nullable
+    column would happily store the same "any nozzle" row twice.
+    """
+
+    __tablename__ = "spool_filament_preset"
+
+    __table_args__ = (UniqueConstraint("spool_id", "printer_model", "nozzle_diameter"),)
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    spool_id: Mapped[int] = mapped_column(ForeignKey("spool.id", ondelete="CASCADE"), index=True)
+    # Matches ``printers.model`` ("X1C", "H2D", "A1 mini"), not a display name.
+    printer_model: Mapped[str] = mapped_column(String(50))
+    # "" = any nozzle of this model; otherwise the bare decimal the printer
+    # reports ("0.4", "0.2"), the same form ``spool_k_profile`` stores.
+    nozzle_diameter: Mapped[str] = mapped_column(String(10), default="")
+    # Wider than ``Spool.slicer_filament`` (String(50)) on purpose: the same
+    # values reach the Spoolman path, whose write schema already allows 128 /
+    # 255, and a preset id that fits there must not truncate here.
+    slicer_filament: Mapped[str | None] = mapped_column(String(128))
+    slicer_filament_name: Mapped[str | None] = mapped_column(String(255))
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+
+    spool: Mapped["Spool"] = relationship(back_populates="filament_presets")
+
+
+class SpoolmanFilamentPreset(Base):
+    """``SpoolFilamentPreset`` for a Spoolman-managed spool.
+
+    Mirrors ``SpoolmanKProfile``: Spoolman owns the spool, Bambuddy owns this
+    override, so the row is local and keyed by the remote spool id with no
+    foreign key to enforce it. Kept in a Bambuddy table rather than in the
+    spool's Spoolman ``extra`` dict for the same reason the K profiles are --
+    it is Bambu-specific data that no other Spoolman client can use, and the
+    extra dict cannot express a per-model list without hand-rolled JSON.
+    """
+
+    __tablename__ = "spoolman_filament_preset"
+
+    __table_args__ = (UniqueConstraint("spoolman_spool_id", "printer_model", "nozzle_diameter"),)
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    spoolman_spool_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
+    printer_model: Mapped[str] = mapped_column(String(50))
+    nozzle_diameter: Mapped[str] = mapped_column(String(10), default="")
+    slicer_filament: Mapped[str | None] = mapped_column(String(128))
+    slicer_filament_name: Mapped[str | None] = mapped_column(String(255))
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+
+
+from backend.app.models.spool import Spool  # noqa: E402, F401

+ 11 - 1
backend/app/schemas/archive.py

@@ -1,7 +1,7 @@
 from datetime import datetime
 from typing import Annotated
 
-from pydantic import BaseModel, BeforeValidator, model_validator
+from pydantic import BaseModel, BeforeValidator, Field, model_validator
 
 from backend.app.utils.filename import clean_display_name
 
@@ -28,6 +28,12 @@ class ArchiveUpdate(ArchiveBase):
     project_id: int | None = None
     # Allow changing status (e.g., clearing failed flag)
     status: str | None = None
+    # Editable because a print archived without its 3MF has no figure at all,
+    # and nothing else can supply one after the fact -- rescan needs a file
+    # this archive does not have (#1820). Bounded because it feeds the filament
+    # totals: 100 kg is far past any single print and well short of a value
+    # that would swamp a chart.
+    filament_used_grams: Annotated[float | None, Field(ge=0, le=100_000)] = None
 
 
 class ArchiveDuplicate(BaseModel):
@@ -167,6 +173,10 @@ class ArchiveStats(BaseModel):
     total_cost: float
     prints_by_filament_type: dict
     prints_by_printer: dict
+    # Name each printer id was last recorded under in the print log. Lets the
+    # client keep labelling history that belongs to a deleted printer (#2873);
+    # a printer that still exists is named from the live record instead.
+    printer_names: dict[str, str] = {}
     # Time accuracy stats
     # Average across all prints with data
     average_time_accuracy: float | None = None

+ 140 - 0
backend/app/schemas/location_ha_sensor.py

@@ -0,0 +1,140 @@
+"""Schemas for Home Assistant entities bound to a storage location (#2824)."""
+
+from datetime import datetime
+from typing import Literal
+
+from pydantic import BaseModel, Field, model_validator
+
+from backend.app.schemas.printer_ha_sensor import HADisplayEntity  # noqa: F401
+
+
+class LocationHASensorBase(BaseModel):
+    location_id: int
+    name: str = Field(..., min_length=1, max_length=100)
+    # max_length matches the column (String(255)). The pattern's [a-z0-9_]+ is
+    # unbounded, so a direct API caller — the picker only ever offers real
+    # Home Assistant ids — could send a longer one: SQLite stores it, but
+    # PostgreSQL raises DataError, and the create/update routes only map
+    # IntegrityError, so it would surface as a 500 instead of a 422.
+    entity_id: str = Field(..., max_length=255, 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
+    # allow_inf_nan=False: pydantic's lax mode coerces the strings "nan"/"inf"
+    # into real NaN/Infinity floats. A NaN threshold satisfies the "notify
+    # needs an alert condition" rule below yet every comparison against it is
+    # False — a notification that can never fire — and it skips the
+    # below-vs-above ordering check the same way. Responses serialize NaN as
+    # null, so the UI would show an empty field over a poisoned row.
+    alert_above: float | None = Field(default=None, allow_inf_nan=False)
+    alert_below: float | None = Field(default=None, allow_inf_nan=False)
+
+    notify_on_alert: bool = False
+    show_on_card: bool = True
+    sort_order: int = Field(default=0, ge=0, le=999)
+
+    @model_validator(mode="after")
+    def validate_kind_matches_entity(self) -> "LocationHASensorBase":
+        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 battery sensor and an
+        # on/off alert on a temperature reading 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")
+
+        # A notification with nothing to trigger on would never fire — that
+        # reads as a broken feature, not as a no-op.
+        if self.notify_on_alert and not self._has_alert_condition():
+            raise ValueError("notify_on_alert requires 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 LocationHASensorCreate(LocationHASensorBase):
+    pass
+
+
+class LocationHASensorUpdate(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)
+    # Same column-width bound as the base schema; PATCH reaches the same row.
+    entity_id: str | None = Field(default=None, max_length=255, 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
+    # Same allow_inf_nan story as the base schema. The route's merged-row
+    # re-validation would catch these too, but rejecting them here keeps the
+    # error attached to the offending field.
+    alert_above: float | None = Field(default=None, allow_inf_nan=False)
+    alert_below: float | None = Field(default=None, allow_inf_nan=False)
+    notify_on_alert: bool | None = None
+    show_on_card: bool | None = None
+    sort_order: int | None = Field(default=None, ge=0, le=999)
+
+
+class LocationHASensorResponse(LocationHASensorBase):
+    # Reads must tolerate what writes now reject, or one legacy row 500s the
+    # whole list. Three constraints are relaxed here on purpose:
+    #
+    # * the NaN/inf thresholds a row could carry before allow_inf_nan landed —
+    #   serialization turns them into null, which is also what the edit form
+    #   should show;
+    # * the entity_id length bound, for a row created before max_length existed
+    #   (SQLite never enforced the column's 255, so those rows are real);
+    # * the entity_id pattern, which the same generation of rows predates.
+    #
+    # Every one of them is still rejected on the way in, so this widens what
+    # can be read back, never what can be stored.
+    alert_above: float | None = None
+    alert_below: float | None = None
+    entity_id: str
+
+    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 LocationHASensorReading(BaseModel):
+    """One sensor's live state, as the filament card and inventory table render 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
+    reachable: bool = True
+    alert_state: str | None = None
+    alert_above: float | None = None
+    alert_below: float | None = None
+    last_changed: datetime | None = None
+    # Lets a consumer that fetched the unfiltered (show_on_card=False) list
+    # still pick out the card-visible subset itself, instead of issuing a
+    # second request for the same location.
+    show_on_card: bool = True

+ 65 - 3
backend/app/schemas/notification.py

@@ -3,7 +3,7 @@
 from datetime import datetime
 from typing import Any
 
-from pydantic import BaseModel, Field, field_validator
+from pydantic import BaseModel, Field, field_validator, model_validator
 
 from backend.app.core.compat import StrEnum
 
@@ -65,11 +65,17 @@ class NotificationProviderBase(BaseModel):
         default=False, description="Notify when AMS-HT temperature exceeds threshold"
     )
 
-    # Event triggers - Home Assistant sensors (#1148)
+    # Event triggers - Home Assistant sensors bound to a printer (#1148)
     on_ha_sensor_alert: bool = Field(
         default=False, description="Notify when a bound Home Assistant sensor enters its alert state"
     )
 
+    # Event triggers - Home Assistant sensors bound to a storage location (#2824)
+    on_location_ha_sensor_alert: bool = Field(
+        default=False,
+        description="Notify when a Home Assistant sensor bound to a storage location 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(
@@ -82,6 +88,19 @@ class NotificationProviderBase(BaseModel):
     # Event triggers - First layer complete
     on_first_layer_complete: bool = Field(default=False, description="Notify when first layer completes")
 
+    # Event triggers - Inventory stock alerts
+    # Missing from this schema until now, so every payload naming them was
+    # dropped silently: the UI's toggles round-tripped as 200 OK and the row
+    # never changed, and _provider_to_dict never returned them either, so they
+    # always read back off. The columns and the sending code have existed since
+    # the inventory forecast landed.
+    on_stock_reorder_alert: bool = Field(
+        default=False, description="Notify when an inventory SKU hits its reorder point"
+    )
+    on_stock_break_alert: bool = Field(
+        default=False, description="Notify when stock will run out before replenishment arrives"
+    )
+
     # Event triggers - Print queue
     on_queue_job_added: bool = Field(default=False, description="Notify when job is added to queue")
     on_queue_job_assigned: bool = Field(default=False, description="Notify when model-based job is assigned to printer")
@@ -159,9 +178,12 @@ class NotificationProviderUpdate(BaseModel):
     on_ams_ht_humidity_high: bool | None = None
     on_ams_ht_temperature_high: bool | None = None
 
-    # Event triggers - Home Assistant sensors (#1148)
+    # Event triggers - Home Assistant sensors bound to a printer (#1148)
     on_ha_sensor_alert: bool | None = None
 
+    # Event triggers - Home Assistant sensors bound to a storage location (#2824)
+    on_location_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
@@ -172,6 +194,10 @@ class NotificationProviderUpdate(BaseModel):
     # Event triggers - First layer complete
     on_first_layer_complete: bool | None = None
 
+    # Event triggers - Inventory stock alerts
+    on_stock_reorder_alert: bool | None = None
+    on_stock_break_alert: bool | None = None
+
     # Event triggers - Print queue
     on_queue_job_added: bool | None = None
     on_queue_job_assigned: bool | None = None
@@ -197,6 +223,42 @@ class NotificationProviderUpdate(BaseModel):
 class NotificationProviderResponse(NotificationProviderBase):
     """Schema for notification provider API responses."""
 
+    @model_validator(mode="before")
+    @classmethod
+    def _null_event_flags_read_as_off(cls, data: Any) -> Any:
+        """Read a NULL event flag as off instead of failing the whole response.
+
+        Every on_* column on notification_providers is nullable with no server
+        default -- the values come from the ORM at INSERT time. A row created
+        before a flag's column existed keeps NULL there forever unless a
+        migration backfills it, and one that did not (the column was created by
+        Base.metadata before run_migrations, so the ALTER ... DEFAULT false was
+        swallowed as a duplicate) leaves NULLs behind on a live install.
+
+        Those NULLs are harmless until the flag is declared on this schema: the
+        Response inherits the write model, so `bool` is then required on the way
+        out, pydantic rejects None, and every provider row fails at once -- the
+        list route 500s and the UI renders an empty list, which reads to the user
+        as "my providers are gone". That is exactly what shipped in #2827.
+
+        Off is not a guess: _get_providers_for_event selects on `.is_(True)`, so
+        the sender already skips a NULL flag. This makes the read agree with the
+        behaviour the row already has, rather than with the field's declared
+        default -- some of which are True, and none of which should switch a
+        notification on as a side effect of repairing a legacy row.
+
+        Writes are untouched: Create and Update inherit from the base, not here,
+        so a payload sending null for a flag is still a 422.
+        """
+        # Every route returns _provider_to_dict(); anything else (an ORM object
+        # via from_attributes) is passed through for pydantic to handle.
+        if not isinstance(data, dict):
+            return data
+        flags = [name for name, f in cls.model_fields.items() if f.annotation is bool]
+        if any(data.get(name, False) is None for name in flags):
+            data = {**data, **{name: False for name in flags if data.get(name, False) is None}}
+        return data
+
     id: int
     last_success: datetime | None = None
     last_error: str | None = None

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

@@ -91,6 +91,7 @@ EVENT_VARIABLES: dict[str, list[str]] = {
     ],
     "bed_cooled": ["printer", "bed_temp", "threshold", "filename", "timestamp", "app_name"],
     "ha_sensor_alert": ["printer", "sensor", "state", "timestamp", "app_name"],
+    "location_ha_sensor_alert": ["location", "sensor", "state", "timestamp", "app_name"],
     "test": ["app_name", "timestamp"],
     # Queue notifications
     "queue_job_added": ["job_name", "target", "timestamp", "app_name"],
@@ -243,6 +244,13 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "timestamp": "2024-01-15 14:30",
         "app_name": "Bambuddy",
     },
+    "location_ha_sensor_alert": {
+        "location": "Drybox 1",
+        "sensor": "Drybox 1 Humidity",
+        "state": "68.00 %",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "Bambuddy",
+    },
     "test": {
         "app_name": "Bambuddy",
         "timestamp": "2024-01-15 14:30",

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

@@ -444,6 +444,9 @@ class PrintBatchPlateProgress(BaseModel):
     estimated_remaining_cost: float | None = None
     filament_used_grams: float | None = None
     print_time_seconds: int = 0
+    # False when this plate owes runs but has no queue item left to clone
+    # their configuration from, so offering to queue it would only fail.
+    can_dispatch: bool = False
 
 
 class PrintBatchResponse(BaseModel):
@@ -475,6 +478,9 @@ class PrintBatchResponse(BaseModel):
     has_targets: bool = False
     target_count: int = 0
     remaining_count: int = 0
+    # Of ``remaining_count``, how many runs can actually be queued. Lower when
+    # a plate's last queue item was deleted (#2960).
+    dispatchable_count: int = 0
     actual_cost: float | None = None
     estimated_remaining_cost: float | None = None
     filament_used_grams: float | None = None

+ 63 - 1
backend/app/schemas/printer.py

@@ -1,6 +1,6 @@
 from datetime import datetime
 
-from pydantic import BaseModel, Field, field_validator
+from pydantic import BaseModel, Field, field_validator, model_validator
 
 from backend.app.utils.printer_models import supports_nozzle_flow_type
 
@@ -170,6 +170,15 @@ class HMSErrorResponse(BaseModel):
     # truncated short_code that historically caused silent command rejection
     # (#1830, H2D wrong-plate verification).
     full_code: str = ""
+    # The bundled catalogue's sentence for this fault, so a client does not have
+    # to carry its own copy of the same table to tell a user why a print halted
+    # (#2926). English only and not localized — the catalogue ships one language.
+    # None when the catalogue does not cover the code, which is common for
+    # `hms[]`-array faults: those resolve through a lossy collapse of their
+    # 16-char identifier and many land on no key at all (#2728). A client should
+    # treat null as "no text available", never as "no fault" — `full_code` is
+    # what identifies the fault, and it is always present.
+    description: str | None = None
 
 
 class AMSTray(BaseModel):
@@ -267,6 +276,25 @@ class FilaSwitchResponse(BaseModel):
     out_extruders: list[int] = []
     stat: int = 0
     info: int = 0
+    # Whether the switch is set up: every AMS bound to one of its two inlets.
+    # A load cannot be routed until it is, so the UI blocks on this rather than
+    # sending a command the firmware will drop.
+    ready: bool = False
+
+
+class ExtruderSlotResponse(BaseModel):
+    """Which AMS slot one hotend is currently fed from.
+
+    From ``device.extruder.info[i].snow``. Needed because ``tray_now`` is a
+    single printer-wide value: on a dual-nozzle machine with both hotends
+    loaded it names only one of them, so it cannot say which hotend holds a
+    given slot.
+    """
+
+    # None when the hotend is not fed from any slot.
+    ams_id: int | None = None
+    slot_id: int | None = None
+    has_filament: bool = False
 
 
 class PrintOptionsResponse(BaseModel):
@@ -338,6 +366,16 @@ class PrinterStatus(BaseModel):
     # Filament Track Switch (FTS) accessory — when installed, AMS reports
     # bits 8-11 = 0xE (uninitialized) and routing is dynamic via the FTS. See #1162.
     fila_switch: FilaSwitchResponse | None = None
+    # Per-AMS FTS inlet binding: {ams_id: "A" | "B"}, from AMS info bits 24-27.
+    # Which of the switch's two inlets each AMS is plumbed into, as set on the
+    # printer's "Manual AMS Setup" screen. Empty unless an FTS is installed —
+    # an FTS-bound AMS reaches BOTH nozzles, so it has no entry in
+    # ams_extruder_map and must not be labelled left or right.
+    ams_switch_inlet: dict[str, str] = {}
+    # Which AMS slot each hotend is fed from, keyed by extruder id as a string
+    # ("0" = right/main, "1" = left/deputy). Empty on printers that do not
+    # report ``device.extruder.info``.
+    extruder_slots: dict[str, ExtruderSlotResponse] = {}
     # Currently loaded tray (global ID): 254 = external spool, 255 = no filament
     tray_now: int = 255
     # Runout / filament-replacement guidance (#2587). Populated only while the
@@ -439,3 +477,27 @@ class DiagnosticRequest(BaseModel):
     ip_address: str
     serial_number: str | None = None
     access_code: str | None = None
+
+
+class PrinterFilesDownloadRequest(BaseModel):
+    """Printer paths selected for a bulk download."""
+
+    paths: list[str] = Field(..., max_length=1000)
+    sizes: dict[str, int] = Field(default_factory=dict, max_length=1000)
+
+    @model_validator(mode="after")
+    def _validate_sizes(self):
+        """Validate optional FTP-reported sizes used for early rejection."""
+
+        if self.sizes and set(self.sizes) != set(self.paths):
+            raise ValueError("A size is required for every selected printer path")
+        if any(size < 0 for size in self.sizes.values()):
+            raise ValueError("Printer file sizes must not be negative")
+        return self
+
+
+class PrinterFilesJobRequest(PrinterFilesDownloadRequest):
+    """Browser preparation request, including native download presentation."""
+
+    filename: str = Field(default="printer-files.zip", min_length=1, max_length=255)
+    as_zip: bool = True

+ 15 - 2
backend/app/schemas/printer_ha_sensor.py

@@ -9,7 +9,11 @@ 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_]+$")
+    # max_length matches the column (String(255)). The pattern's [a-z0-9_]+ is
+    # unbounded, so a direct API caller could send a longer id: SQLite stores
+    # it, PostgreSQL raises DataError, and it would surface as a 500 rather
+    # than a 422. Same bound as the location sibling.
+    entity_id: str = Field(..., max_length=255, 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)
@@ -59,7 +63,8 @@ class PrinterHASensorUpdate(BaseModel):
     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_]+$")
+    # Same column-width bound as the base schema; PATCH reaches the same row.
+    entity_id: str | None = Field(default=None, max_length=255, 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)
@@ -73,6 +78,14 @@ class PrinterHASensorUpdate(BaseModel):
 
 
 class PrinterHASensorResponse(PrinterHASensorBase):
+    # Reads stay tolerant of what writes now reject: this feature shipped
+    # before entity_id was bounded, and SQLite never enforced the column's 255,
+    # so a row longer than that can genuinely exist. Inheriting the bound would
+    # turn it into a 500 on the list route — the same failure the bound was
+    # added to prevent, moved from the write path to the read path. The pattern
+    # is dropped with it, for the same generation of rows. Writes are unchanged.
+    entity_id: str
+
     id: int
     last_state: str | None = None
     last_changed: datetime | None = None

+ 43 - 0
backend/app/schemas/scheduled_drying.py

@@ -0,0 +1,43 @@
+from datetime import datetime
+from typing import Annotated
+
+from pydantic import AfterValidator, BaseModel, Field
+
+from backend.app.schemas.print_queue import UTCDatetime
+from backend.app.utils.local_time import to_naive_utc
+
+# Coerces any client-sent UTC offset to the naive UTC the DB stores.
+NaiveUTCDatetime = Annotated[datetime | None, AfterValidator(to_naive_utc)]
+
+
+class ScheduledDryingCreate(BaseModel):
+    printer_id: int
+    ams_id: int = 0
+    temp: int = Field(ge=45, le=85)
+    duration_hours: int = Field(ge=1, le=24)
+    # max_length matches the String(50) column: without it PostgreSQL 500s on
+    # an over-long value and SQLite silently accepts it.
+    filament: str = Field("", max_length=50)
+    rotate_tray: bool = False
+    start_after: NaiveUTCDatetime = None
+
+
+class ScheduledDryingResponse(BaseModel):
+    id: int
+    printer_id: int
+    ams_id: int
+    temp: int
+    duration_hours: int
+    filament: str
+    rotate_tray: bool
+    # UTCDatetime, not a bare datetime: every queue route sends the Z suffix and
+    # the frontend parses these the same way.
+    start_after: UTCDatetime
+    status: str
+    waiting_reason: str | None
+    error_message: str | None
+    created_at: UTCDatetime
+    started_at: UTCDatetime
+    completed_at: UTCDatetime
+
+    model_config = {"from_attributes": True}

+ 40 - 0
backend/app/schemas/settings.py

@@ -115,6 +115,18 @@ class AppSettings(BaseModel):
     ams_temp_fair: float = Field(
         default=35.0, description="Temperature threshold for fair (orange): <= this value, > is red"
     )
+    # Separate from ams_temp_fair on purpose (#2905). The fair threshold decides
+    # when the AMS card turns amber; this decides when a notification is sent.
+    # 35 C is a sensible place to change a colour and not a sensible place to
+    # page someone -- a room above 35 C makes the alarm fire once an hour for as
+    # long as the weather lasts, and the only way to silence it was to raise the
+    # display band and lose the colour that says the unit is warm. None means
+    # "not set", which resolves to ams_temp_fair so every existing install keeps
+    # behaving exactly as it does now.
+    ams_temp_alarm: float | None = Field(
+        default=None,
+        description="Temperature threshold (°C) for sending an alarm. Unset falls back to ams_temp_fair.",
+    )
     ams_history_retention_days: int = Field(default=30, description="Number of days to keep AMS sensor history data")
     printer_sensor_history_retention_days: int = Field(
         default=30, description="Number of days to keep printer heater history data (nozzle / bed / chamber)"
@@ -606,6 +618,27 @@ class AppSettings(BaseModel):
         description="Global lead time floor (days) used in reorder point calculation for all SKUs",
     )
 
+    location_sensor_poll_interval: int = Field(
+        default=120,
+        ge=60,
+        le=3600,
+        description="Seconds between Home Assistant polls/UI refreshes for storage-location sensors",
+    )
+    # Server-backed rather than per-browser: these seed the alert rule written
+    # onto each sensor row when one is bound, so two admins binding sensors
+    # from different browsers must not seed different rules — and a restore
+    # has to bring them back. The "show on card" default stays local, because
+    # show_on_card is decided per sensor and this is only its form
+    # pre-selection. Same JSON-in-a-string shape as preheat_filament_targets.
+    location_sensor_alert_defaults: str = Field(
+        default="",
+        description=(
+            "JSON map of sensor category (temperature/humidity/battery) → "
+            '{"alertAbove": str, "alertBelow": str, "notifyOnAlert": bool}, seeding new '
+            "storage-location sensor bindings. Empty = built-in defaults."
+        ),
+    )
+
     # Default sidebar order (admin-set for all users)
     default_sidebar_order: str = Field(
         default="",
@@ -643,6 +676,7 @@ class AppSettingsUpdate(BaseModel):
     ams_humidity_fair: int | None = None
     ams_temp_good: float | None = None
     ams_temp_fair: float | None = None
+    ams_temp_alarm: float | None = None
     ams_history_retention_days: int | None = None
     printer_sensor_history_retention_days: int | None = None
     queue_drying_enabled: bool | None = None
@@ -748,6 +782,12 @@ class AppSettingsUpdate(BaseModel):
     obico_enabled_printers: str | None = None
     default_sidebar_order: str | None = None
     forecast_global_lead_time_days: int | None = Field(default=None, ge=0)
+    location_sensor_poll_interval: int | None = Field(default=None, ge=60, le=3600)
+    # Three categories × three short fields is well under 300 characters of
+    # JSON, so 2000 is pure headroom — the cap only stops a stray client from
+    # parking megabytes in the settings table. Write path only: the AppSettings
+    # read model must keep accepting whatever an older install already stored.
+    location_sensor_alert_defaults: str | None = Field(default=None, max_length=2000)
 
     @field_validator(*LAN_SERVICE_URL_SETTINGS)
     @classmethod

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

@@ -1,9 +1,15 @@
 """Pydantic schemas for slice requests."""
 
+import re
 from typing import Any, Literal
 
 from pydantic import BaseModel, Field, model_validator
 
+# `#RRGGBB` or `#RRGGBBAA`. Bambu Studio writes the 6-digit form into
+# `filament_colour` but accepts and round-trips the 8-digit one, and the AMS
+# reports colours with an alpha byte, so both have to pass.
+_HEX_COLOUR = re.compile(r"#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})")
+
 
 class PresetRef(BaseModel):
     """A source-aware reference to a printer / process / filament preset.
@@ -67,6 +73,34 @@ class SliceRequest(BaseModel):
     # is empty so older clients keep working.
     filament_presets: list[PresetRef] = Field(default_factory=list)
 
+    # Per-slot filament colour, plate-slot-ordered like ``filament_presets``.
+    # Neither Bambu Studio nor OrcaSlicer store a colour on a *filament preset*
+    # — it is a per-project property their GUIs set from the plate — so the CLI
+    # falls back to its compiled-in default (#00AE42, Bambu green) for every
+    # slice unless something supplies one. That default is what #2977 saw: a
+    # green plate thumbnail, `filament_colour = #00AE42` in the output, and a
+    # "Color mismatch" against the AMS slot the print was mapped to.
+    #
+    # `default_filament_colour` is NOT a substitute. Measured against a
+    # 02.08.02.61 sidecar: sending it alone leaves `filament_colour` at
+    # #00AE42, because the CLI never reads it — it is consumed by the GUI when
+    # initialising a project. The colour has to be written to `filament_colour`
+    # itself, which is what this field ends up doing.
+    filament_colours: list[str] = Field(
+        default_factory=list,
+        description=(
+            "Per-slot filament colour as ``#RRGGBB`` / ``#RRGGBBAA``, in the same "
+            "plate-slot order as ``filament_presets``. Written onto each resolved "
+            "filament profile as ``filament_colour`` so the sliced file records the "
+            "colour actually being printed instead of the slicer's built-in default "
+            "(#2977). A shorter list than ``filament_presets`` leaves the remaining "
+            "slots to the fallback chain; an empty string in any position does the "
+            "same for that one slot. An omitted list (older clients) falls back to "
+            "the preset's own ``default_filament_colour``, then to the colour the "
+            "source file's plate was designed with."
+        ),
+    )
+
     plate: int | None = Field(
         default=None,
         ge=0,
@@ -90,7 +124,11 @@ class SliceRequest(BaseModel):
             "preset (#2622) — the designer's own wall count, infill, first-layer "
             "height and so on, which ``--load-settings`` would otherwise discard. "
             "Only keys the source actually lists as changed are applied; anything "
-            "else is ignored. ``None``/empty means a plain profile slice."
+            "else is ignored. An empty list is not the same answer as ``None``: "
+            "it says the caller was shown the file's settings and chose none of "
+            "them, which also holds back the support carry-over (#1881) for the "
+            "support keys the file offered, while ``None`` — a caller that "
+            "predates the per-key choice — leaves that carry-over unconditional."
         ),
     )
     process_overrides: dict[str, Any] | None = Field(
@@ -197,6 +235,23 @@ class SliceRequest(BaseModel):
             # Multi-color caller: backfill the singular from the first slot
             # so callers that still read the legacy field see a stable value.
             self.filament_preset = self.filament_presets[0]
+
+        # Colours are pasted straight into a profile the slicer parses, so a
+        # malformed one is rejected here rather than passed through. Empty
+        # strings survive: they are how a caller says "no colour for this
+        # slot" without having to shorten the list and shift every slot after
+        # it. Normalised to upper-case so a slice never differs from another
+        # only by the case of a hex digit.
+        normalised: list[str] = []
+        for i, colour in enumerate(self.filament_colours):
+            value = (colour or "").strip()
+            if not value:
+                normalised.append("")
+                continue
+            if not _HEX_COLOUR.fullmatch(value):
+                raise ValueError(f"filament_colours[{i}] must be '#RRGGBB' or '#RRGGBBAA', got {colour!r}")
+            normalised.append("#" + value[1:].upper())
+        self.filament_colours = normalised
         return self
 
 

+ 25 - 0
backend/app/schemas/spool.py

@@ -198,6 +198,31 @@ class SpoolKProfileResponse(SpoolKProfileBase):
         from_attributes = True
 
 
+class SpoolFilamentPresetBase(BaseModel):
+    """One per-printer-model slicer preset override for a spool.
+
+    ``nozzle_diameter`` defaults to "" meaning "any nozzle of this model". The
+    spool form always sends a concrete size; the empty form is for API clients
+    that want one value to cover a model. Lengths match the columns, which are wider than
+    ``Spool.slicer_filament`` so a preset id that fits the Spoolman write
+    schema cannot truncate on the way in.
+    """
+
+    printer_model: str = Field(..., min_length=1, max_length=50)
+    nozzle_diameter: str = Field(default="", max_length=10)
+    slicer_filament: str | None = Field(default=None, max_length=128)
+    slicer_filament_name: str | None = Field(default=None, max_length=255)
+
+
+class SpoolFilamentPresetResponse(SpoolFilamentPresetBase):
+    id: int
+    spool_id: int
+    created_at: datetime
+
+    class Config:
+        from_attributes = True
+
+
 class SpoolResponse(SpoolBase):
     id: int
     # rgba is intentionally unconstrained on the response side: the write paths

+ 330 - 132
backend/app/services/archive.py

@@ -18,8 +18,11 @@ 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.archive_paths import archive_dir as resolve_archive_dir
+from backend.app.utils.ffmpeg_output import NO_FFMPEG_OUTPUT, summarize_ffmpeg_stderr
 from backend.app.utils.filename import clean_display_name
-from backend.app.utils.safe_path import PathTraversalError, safe_join_under
+from backend.app.utils.safe_path import PathTraversalError, assert_under, safe_join_under
+from backend.app.utils.threemf_tools import bed_temperature_from_config
 
 logger = logging.getLogger(__name__)
 
@@ -89,6 +92,25 @@ def _read_plate_index(plate) -> int | None:
     return None
 
 
+def plate_indexes_in_3mf(file_path: Path) -> list[int | None]:
+    """Return one entry per ``<plate>`` a Bambu 3MF declares, in file order.
+
+    Reads only ``Metadata/slice_info.config``. An entry is None when that plate
+    carries no readable index, and the list is empty for a file that could not
+    be read at all — callers must not confuse either with "this file has plates
+    and yours is not among them". An unreadable 3MF is a parse this code does
+    not understand, not evidence about which plate it holds (#2957).
+    """
+    try:
+        with zipfile.ZipFile(file_path, "r") as zf:
+            if "Metadata/slice_info.config" not in zf.namelist():
+                return []
+            root = ET.fromstring(zf.read("Metadata/slice_info.config").decode())
+            return [_read_plate_index(plate) for plate in root.findall(".//plate")]
+    except Exception:
+        return []
+
+
 def peek_plate_index_in_3mf(file_path: Path) -> int | None:
     """Return the plate index a single-plate Bambu 3MF represents, or None.
 
@@ -102,18 +124,8 @@ def peek_plate_index_in_3mf(file_path: Path) -> int | None:
     plate 1 out of such a file, declaring a mismatch against the plate that
     is really running, and discarding a perfectly good 3MF (#2522).
     """
-    try:
-        with zipfile.ZipFile(file_path, "r") as zf:
-            if "Metadata/slice_info.config" not in zf.namelist():
-                return None
-            content = zf.read("Metadata/slice_info.config").decode()
-            root = ET.fromstring(content)
-            plates = root.findall(".//plate")
-            if len(plates) != 1:
-                return None
-            return _read_plate_index(plates[0])
-    except Exception:
-        return None
+    plates = plate_indexes_in_3mf(file_path)
+    return plates[0] if len(plates) == 1 else None
 
 
 _PLATE_SUFFIX_RE = re.compile(r"^(.*?)(\s*-\s*Plate\s+|_plate_)(\d+)$", re.IGNORECASE)
@@ -138,6 +150,14 @@ def swap_plate_suffix(name: str | None, target_plate: int) -> str | None:
     return f"{base}{separator}{target_plate}"
 
 
+# How much of a plate's G-code to scan for header/config values. The header
+# block ends in the first kilobyte; the CONFIG_BLOCK that follows it carries
+# layer_height 14-25KB in (measured across the sliced 3MFs on hand, Bambu
+# Studio and OrcaSlicer alike), so 4KB — what this used to read — could only
+# ever see the header.
+_GCODE_SCAN_BYTES = 64 * 1024
+
+
 class ThreeMFParser:
     """Parser for Bambu Lab 3MF files."""
 
@@ -347,24 +367,65 @@ class ThreeMFParser:
         except Exception:
             pass  # Skip unreadable project settings file
 
+    def _printed_plate_gcode(self, gcode_files: list[str]) -> str:
+        """Return the G-code entry for the plate this archive is about.
+
+        ``plate_number`` is known for a plate-specific export (slice_info sets
+        it) and picking blindly is wrong there: a project sliced with plate 2
+        at 0.08 and plate 1 at 0.2 would otherwise report plate 1's numbers.
+        Falls back to the lowest plate index, then to zip order, so a file
+        whose entries are named some other way still parses as it did before.
+        """
+        if self.plate_number:
+            wanted = f"Metadata/plate_{self.plate_number}.gcode"
+            if wanted in gcode_files:
+                return wanted
+
+        def plate_index(name: str) -> int:
+            match = re.search(r"plate_(\d+)\.gcode$", name)
+            return int(match.group(1)) if match else 10**6
+
+        return min(gcode_files, key=lambda name: (plate_index(name), gcode_files.index(name)))
+
     def _parse_gcode_header(self, zf: zipfile.ZipFile):
-        """Parse G-code file header for total layer count and printer model."""
+        """Parse the printed plate's G-code for what only it can settle.
+
+        The plate's own G-code is the file the printer executes, so where it
+        disagrees with ``project_settings.config`` — the *project's* record,
+        which a multi-plate or per-plate-modified export can leave describing
+        a different plate entirely — the G-code wins.
+        """
         try:
-            # Look for plate_1.gcode or similar
             gcode_files = [f for f in zf.namelist() if f.endswith(".gcode")]
             if not gcode_files:
                 return
 
-            # Read first 4KB of G-code (header contains metadata)
-            gcode_path = gcode_files[0]
+            gcode_path = self._printed_plate_gcode(gcode_files)
+            # 64KB, not 4KB: the header block ends within the first kilobyte,
+            # but the CONFIG_BLOCK that carries layer_height starts right after
+            # it and the keys are alphabetical, so layer_height lands 14-25KB
+            # in on real files. The read is decompress-on-demand, so the cost
+            # of the wider window is a few tens of KB per archived file.
             with zf.open(gcode_path) as f:
-                header = f.read(4096).decode("utf-8", errors="ignore")
+                header = f.read(_GCODE_SCAN_BYTES).decode("utf-8", errors="ignore")
 
             # Look for "; total layer number: XX" pattern
             match = re.search(r";\s*total\s+layer\s+number[:\s]+(\d+)", header, re.IGNORECASE)
             if match:
                 self.metadata["total_layers"] = int(match.group(1))
 
+            # Layer height, overriding project_settings.config when both are
+            # present. The project config records the project's settings and can
+            # describe a plate other than this one; the plate's G-code is what
+            # the printer executes, so it decides. Anchored to the line start so keys ending in
+            # "layer_height" (independent_support_layer_height) can't match.
+            match = re.search(r"^;\s*layer_height\s*=\s*([\d.]+)\s*$", header, re.IGNORECASE | re.MULTILINE)
+            if match:
+                try:
+                    self.metadata["layer_height"] = float(match.group(1))
+                except ValueError:
+                    pass  # Malformed value: keep whatever project_settings gave us
+
             # Total filament usage. The slicer writes the print's totals into
             # the G-code header ("; total filament weight [g] : 126.26"). Only
             # a fallback — slice_info.config is more authoritative when present
@@ -448,15 +509,14 @@ class ThreeMFParser:
                 elif isinstance(val, (int, float, str)):
                     self.metadata["nozzle_diameter"] = float(val)
 
-            # Bed temperature - first layer or regular
-            for key in ["bed_temperature_initial_layer", "bed_temperature"]:
-                if key in data:
-                    val = data[key]
-                    if isinstance(val, list) and val:
-                        self.metadata["bed_temperature"] = int(float(val[0]))
-                    elif isinstance(val, (int, float, str)):
-                        self.metadata["bed_temperature"] = int(float(val))
-                    break
+            # Bed temperature, for the plate this project is sliced for. This
+            # used to look for `bed_temperature` alone, a key BambuStudio does
+            # not write -- so every archive from a Bambu slice stored NULL, and
+            # preheat fell back to a configured bed temperature on every job
+            # (#2989). Orca-exported 3MFs keep working through the generic keys.
+            bed_temperature = bed_temperature_from_config(data)
+            if bed_temperature is not None:
+                self.metadata["bed_temperature"] = bed_temperature
 
             # Nozzle temperature
             for key in ["nozzle_temperature_initial_layer", "nozzle_temperature"]:
@@ -483,30 +543,6 @@ class ThreeMFParser:
         except Exception:
             pass  # Print settings are optional; missing values are left unset
 
-    def _extract_settings_from_content(self, content: str):
-        """Extract print settings from config content."""
-        settings_map = {
-            "layer_height": ("layer_height", float),
-            "nozzle_diameter": ("nozzle_diameter", float),
-            "bed_temperature": ("bed_temperature", int),
-            "nozzle_temperature": ("nozzle_temperature", int),
-        }
-
-        for key, (search_key, converter) in settings_map.items():
-            if key not in self.metadata:
-                try:
-                    # Try JSON format
-                    if f'"{search_key}"' in content:
-                        start = content.find(f'"{search_key}"')
-                        value_start = content.find(":", start) + 1
-                        value_end = content.find(",", value_start)
-                        if value_end == -1:
-                            value_end = content.find("}", value_start)
-                        value = content[value_start:value_end].strip().strip('"')
-                        self.metadata[key] = converter(value)
-                except (ValueError, TypeError):
-                    pass  # Skip settings with unconvertible values
-
     def _parse_3dmodel(self, zf: zipfile.ZipFile):
         """Parse 3D/3dmodel.model for MakerWorld metadata."""
         try:
@@ -602,6 +638,26 @@ class ThreeMFParser:
                 break
 
 
+def extract_printable_objects_from_archive(
+    file_path: Path, plate_number: int | None = None
+) -> tuple[dict[int, dict], list | None]:
+    """Objects and plate bbox for an archived print, read off local disk.
+
+    The archive of a running print usually holds the very 3MF the printer is
+    executing, so the object list can be rebuilt without asking the printer for
+    a file we already have -- 15 MB over FTPS from a machine that is mid-print,
+    in the case this was written for. Returns empty when the archive has
+    no readable 3MF, which is the caller's signal to fall back to the printer.
+    """
+    if not file_path.is_file() or not str(file_path).endswith(".3mf"):
+        return {}, None
+    try:
+        data = file_path.read_bytes()
+    except OSError:
+        return {}, None
+    return extract_printable_objects_from_3mf(data, plate_number=plate_number, include_positions=True)
+
+
 def extract_printable_objects_from_3mf(
     data: bytes, plate_number: int | None = None, include_positions: bool = False
 ) -> dict[int, str] | dict[int, dict] | tuple[dict[int, dict], list | None]:
@@ -1148,6 +1204,7 @@ class ArchiveService:
         library_file_id: int | None = None,
         slicer_ams_mapping: list[int] | None = None,
         slicer_ams_mapping_printer_id: int | None = None,
+        update_archive_id: int | None = None,
     ) -> PrintArchive | None:
         """Archive a 3MF file with metadata.
 
@@ -1185,6 +1242,17 @@ class ArchiveService:
                 reused later on any printer, including the same one (there'd be no way to
                 tell). A model-based VP with no fixed target printer has no valid value to
                 pass here and must leave both params unset.
+            update_archive_id: Fill in an existing archive row instead of adding one.
+                Used to upgrade a no-3MF fallback archive once the file finally arrives
+                (#2957). Everything above the row itself — the copy, the parse, the
+                thumbnail, the cost — is exactly what a fresh archive does; only the
+                destination differs. The row must keep its id: the energy-start reading,
+                the timelapse session, ``_active_prints``, the start notification and any
+                queue link were all written against it while the print was running, and a
+                second row would orphan every one of them. Fields the fallback path
+                already established from MQTT (``started_at``, ``subtask_id``,
+                ``created_by_id``, ``project_id``) are left alone; the 3MF has nothing
+                better to say about them.
         """
         # Verify printer exists if specified
         if printer_id is not None:
@@ -1325,6 +1393,71 @@ class ArchiveService:
             quantity = len(printable_objects)
             logger.debug("Auto-detected %s parts from 3MF printable objects", quantity)
 
+        # Recovery of an existing fallback row: assign the freshly-parsed values
+        # onto it rather than adding a second archive for the same print (#2957).
+        if update_archive_id is not None:
+            existing = await self.db.get(PrintArchive, update_archive_id)
+            if existing is None:
+                logger.warning("archive_print: archive %s to update no longer exists", update_archive_id)
+                return None
+            # `metadata` is freshly parsed from the 3MF, so assigning it drops
+            # the row's `no_3mf_available` / `no_3mf_reason` markers as a side
+            # effect — which is correct, the archive is no longer a fallback,
+            # and it is what stops the Archives banner counting it.
+            # `_print_data` is diagnostic history rather than something the 3MF
+            # knows about: keep the row's copy for a caller that passed no
+            # print_data of its own.
+            merged = dict(metadata)
+            preserved = (existing.extra_data or {}).get("_print_data")
+            if preserved is not None and "_print_data" not in merged:
+                merged["_print_data"] = preserved
+            # A record that this row started life without a 3MF, which the
+            # dropped markers no longer say.
+            merged["recovered_no_3mf"] = True
+            existing.filename = original_filename or source_file.name
+            existing.file_path = str(dest_file.relative_to(settings.base_dir))
+            existing.file_size = dest_file.stat().st_size
+            existing.content_hash = content_hash
+            existing.thumbnail_path = thumbnail_path
+            existing.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))
+            )
+            # Only overwrite what the 3MF actually knows. A fallback archive
+            # recovered mid-print has a real print_time_seconds from MQTT and a
+            # filament type/colour from the AMS; a 3MF that omits a field must
+            # not blank them back out.
+            for field in (
+                "print_time_seconds",
+                "filament_used_grams",
+                "filament_type",
+                "filament_color",
+                "layer_height",
+                "total_layers",
+                "nozzle_diameter",
+                "bed_temperature",
+                "bed_type",
+                "nozzle_temperature",
+                "sliced_for_model",
+                "makerworld_url",
+                "designer",
+            ):
+                value = metadata.get(field)
+                if value is not None:
+                    setattr(existing, field, value)
+            if cost is not None:
+                existing.cost = cost
+            existing.quantity = quantity
+            existing.extra_data = merged
+            if plate_id is not None:
+                existing.plate_id = plate_id
+            if library_file_id is not None:
+                existing.library_file_id = library_file_id
+            await self.db.commit()
+            await self.db.refresh(existing)
+            return existing
+
         # Create archive record
         archive = PrintArchive(
             printer_id=printer_id,
@@ -1476,52 +1609,140 @@ class ArchiveService:
             # the first soft-delete pass so there is nothing left on disk.
             return True
 
-        dir_to_delete = self._resolve_archive_dir_for_delete(archive)
+        dirs_to_delete = self._resolve_archive_dirs_for_delete(archive)
+        recorded_paths = (archive.timelapse_path, archive.thumbnail_path)
 
         await _null_print_log_thumbnail_paths(self.db, archive_id)
         await _delete_related_queue_items(self.db, archive_id)
         archive.deleted_at = datetime.now(timezone.utc)
         await self.db.commit()
 
-        if dir_to_delete:
-            shutil.rmtree(dir_to_delete, ignore_errors=True)
+        for directory in dirs_to_delete:
+            shutil.rmtree(directory, ignore_errors=True)
+        self._purge_id_named_dir(archive_id, recorded_paths)
         return True
 
-    def _resolve_archive_dir_for_delete(self, archive: PrintArchive) -> Path | None:
-        """Return the on-disk directory that backs *archive*, after the same
-        two safety checks ``delete_archive`` enforces.
+    def _resolve_archive_dirs_for_delete(self, archive: PrintArchive) -> list[Path]:
+        """Directories belonging to *archive* alone, safe to remove whole.
+
+        Shared by soft-delete and hard-delete so the two cannot drift apart
+        again — the previous helper said it was extracted for that reason, and
+        ``delete_archive`` was still doing its own copy of the same rules.
+
+        An archive with a 3MF owns the directory its ``file_path`` sits in,
+        ``<archive_dir>/<printer_id>/<timestamp>_<name>/``. Any archive may also
+        own ``archive/no_source/<id>/``, where a source 3MF uploaded onto a
+        no-3MF archive lands (#1531); that one was never removed, so deleting
+        such an archive freed the row and left the upload behind.
+
+        Two directories are deliberately absent. ``<base_dir>/photos`` is the
+        legacy location *every* no-3MF archive wrote into at once, so removing
+        it on one delete would take the others' photos with it. And
+        ``<archive_dir>/<id>`` — the directory :func:`resolve_archive_dir` gives
+        an archive with no ``file_path`` — is handled by
+        :meth:`_purge_id_named_dir` instead, for the reason given there.
+        """
+        candidates: list[Path] = []
+        # Only when there is a path to derive it from. Without one,
+        # ``resolve_archive_dir`` returns the id-named directory, which must not
+        # be removed wholesale -- see _purge_id_named_dir.
+        if archive.file_path and archive.file_path.strip():
+            candidates.append(resolve_archive_dir(archive))
+        candidates.append(settings.archive_dir / "no_source" / str(archive.id))
 
-        Extracted so soft-delete and hard-delete share the path-resolution
-        rules. Returns ``None`` when nothing should be removed from disk
-        (no file_path, path outside archive_dir, or path not deep enough).
+        resolved: list[Path] = []
+        for candidate in candidates:
+            if candidate in resolved or not candidate.is_dir():
+                continue
+            try:
+                relative_path = candidate.resolve().relative_to(settings.archive_dir.resolve())
+            except ValueError:
+                # A genuine guard trip, unlike the empty ``file_path`` this used
+                # to shout about: the row points somewhere outside the archive
+                # tree, which only a corrupted import or hand-edited SQL can do.
+                logger.error(
+                    f"SECURITY: Refusing to delete archive {archive.id} - "
+                    f"path {candidate} is outside archive directory {settings.archive_dir}"
+                )
+                continue
+            # Two deep, not one. An archive directory has been
+            # ``<archive_dir>/<printer_id>/<timestamp>_<name>/`` since the first
+            # commit, so nothing legitimate sits one level down -- but the
+            # per-printer folder does, and it holds every print that printer
+            # ever made. Under the old ``< 1`` a row whose file_path had lost a
+            # path component took the whole folder with it.
+            if len(relative_path.parts) < 2:
+                logger.error(
+                    f"SECURITY: Refusing to delete archive {archive.id} - "
+                    f"path {candidate} is not deep enough inside archive directory"
+                )
+                continue
+            resolved.append(candidate)
+        return resolved
+
+    def _purge_id_named_dir(self, archive_id: int, recorded_paths: tuple[str | None, ...]) -> None:
+        """Remove one archive's own files from ``<archive_dir>/<id>``, carefully.
+
+        Takes the recorded paths rather than the row because ``delete_archive``
+        removes the row before it touches the disk, deliberately: a failed
+        commit must leave the files alone. Reading ``archive.timelapse_path``
+        off a deleted instance afterwards would raise or silently refresh.
+
+        That directory is where an archive with no 3MF keeps its timelapse and
+        its finish photos (:func:`resolve_archive_dir`). It is emphatically NOT
+        an ``rmtree`` target, because it shares a namespace with the per-printer
+        folders: a normal archive lives at
+        ``<archive_dir>/<printer_id>/<timestamp>_<name>/``, so ``archive/1`` is
+        printer 1's folder *and* the directory the helper hands archive id 1.
+        Archive ids and printer ids are both small integers from unrelated
+        sequences, so on any install the first few archives collide with the
+        printers. Removing the directory would take every print that printer
+        ever made — measured on a scratch tree before this guard existed.
+
+        So nothing is removed that has not been identified as this archive's.
+        ``photos`` is a fixed subdirectory name and an archive directory is
+        always ``<timestamp>_<name>``, so the two cannot be confused; the video
+        is removed by the path the row itself records. The directory then goes
+        only if that left it empty, which a printer folder holding prints never
+        will. Anything unrecognised keeps it alive and is leaked rather than
+        guessed at — the safe direction for a recursive delete.
         """
-        if not archive.file_path or not archive.file_path.strip():
-            logger.error(
-                f"SECURITY: Refusing to delete files for archive {archive.id} - "
-                f"file_path is empty or invalid: '{archive.file_path}'"
-            )
-            return None
+        directory = settings.archive_dir / str(archive_id)
+        if not directory.is_dir():
+            return
+        try:
+            relative_path = directory.resolve().relative_to(settings.archive_dir.resolve())
+        except ValueError:
+            return
+        if len(relative_path.parts) != 1:
+            return
 
-        file_path = settings.base_dir / archive.file_path
-        if not file_path.exists():
-            return None
+        shutil.rmtree(directory / "photos", ignore_errors=True)  # SEC-PATH-OK: constant subdirectory
+        for recorded in recorded_paths:
+            if not recorded:
+                continue
+            try:
+                # Two checks, not one. safe_join_under rejects the absolute and
+                # ``..`` shapes and proves the result is inside the data
+                # directory; assert_under then narrows it to *this* archive's
+                # own directory, because a row whose timelapse_path names
+                # another archive's file must not take it with this delete.
+                # The column is written by Bambuddy from a filename the printer
+                # supplied over FTP, so it is not a trusted constant.
+                candidate = safe_join_under(settings.base_dir, recorded, http=False)
+                assert_under(directory, candidate, http=False)
+            except PathTraversalError:
+                continue
+            if candidate.is_file():
+                candidate.unlink(missing_ok=True)
 
-        archive_dir = file_path.parent
         try:
-            relative_path = archive_dir.resolve().relative_to(settings.archive_dir.resolve())
-        except ValueError:
-            logger.error(
-                f"SECURITY: Refusing to delete archive {archive.id} - "
-                f"path {archive_dir} is outside archive directory {settings.archive_dir}"
-            )
-            return None
-        if len(relative_path.parts) < 1:
-            logger.error(
-                f"SECURITY: Refusing to delete archive {archive.id} - "
-                f"path {archive_dir} is not deep enough inside archive directory"
-            )
-            return None
-        return archive_dir
+            directory.rmdir()
+        except OSError:
+            # Not empty (a printer folder, or a file this archive did not
+            # record) or already gone. Both are fine: the point of rmdir over
+            # rmtree is that it cannot take anything with it.
+            pass
 
     async def delete_archive(self, archive_id: int) -> bool:
         """Delete an archive and its files."""
@@ -1529,46 +1750,12 @@ class ArchiveService:
         if not archive:
             return False
 
-        # Resolve the directory to delete BEFORE committing the DB change
-        dir_to_delete: Path | None = None
-
-        if archive.file_path and archive.file_path.strip():
-            file_path = settings.base_dir / archive.file_path
-            if file_path.exists():
-                archive_dir = file_path.parent
-
-                # Safety check 1: archive_dir must be inside archive_dir
-                try:
-                    archive_dir.resolve().relative_to(settings.archive_dir.resolve())
-                except ValueError:
-                    logger.error(
-                        f"SECURITY: Refusing to delete archive {archive_id} - "
-                        f"path {archive_dir} is outside archive directory {settings.archive_dir}"
-                    )
-                    await self.db.delete(archive)
-                    await self.db.commit()
-                    return True
-
-                # Safety check 2: archive_dir must be at least 1 level deep inside archive_dir
-                try:
-                    relative_path = archive_dir.resolve().relative_to(settings.archive_dir.resolve())
-                    if len(relative_path.parts) < 1:
-                        logger.error(
-                            f"SECURITY: Refusing to delete archive {archive_id} - "
-                            f"path {archive_dir} is not deep enough inside archive directory"
-                        )
-                        await self.db.delete(archive)
-                        await self.db.commit()
-                        return True
-                except ValueError:
-                    pass  # Already handled above
-
-                dir_to_delete = archive_dir
-        else:
-            logger.error(
-                f"SECURITY: Refusing to delete files for archive {archive_id} - "
-                f"file_path is empty or invalid: '{archive.file_path}'"
-            )
+        # Resolved BEFORE committing the DB change, since the row is what says
+        # where the files are. Shared with soft-delete rather than repeated
+        # here: this was a second copy of the same checks and it had already
+        # diverged from the one it was extracted from.
+        dirs_to_delete = self._resolve_archive_dirs_for_delete(archive)
+        recorded_paths = (archive.timelapse_path, archive.thumbnail_path)
 
         # NULL stale thumbnail_path on linked PrintLogEntries before the FK
         # SET-NULL cascade fires. The on-disk file is about to be removed by
@@ -1583,8 +1770,9 @@ class ArchiveService:
         await self.db.commit()
 
         # Only delete files AFTER the DB commit succeeds to avoid orphaned records
-        if dir_to_delete:
-            shutil.rmtree(dir_to_delete, ignore_errors=True)
+        for directory in dirs_to_delete:
+            shutil.rmtree(directory, ignore_errors=True)
+        self._purge_id_named_dir(archive_id, recorded_paths)
 
         return True
 
@@ -1605,11 +1793,17 @@ class ArchiveService:
         if not archive:
             return False
 
-        # Get archive directory
-        file_path = (
-            settings.base_dir / archive.file_path
-        )  # SEC-PATH-OK: archive.file_path is DB-stored, set by archive_print() under settings.archive_dir
-        archive_dir = file_path.parent
+        # Where this archive's files live. Deliberately the shared helper: an
+        # archive created without a 3MF has ``file_path == ""``, and deriving the
+        # directory here as ``(base_dir / "").parent`` resolved to the parent of
+        # base_dir — outside the data directory entirely. In Docker that is /app,
+        # so the write failed with EACCES and the timelapse was retried and
+        # discarded 25 times; where the parent happens to be writable it
+        # succeeded, dropped a stray video next to the install, and then failed
+        # anyway on the relative_to() below. Every H2-series and P2S print sent
+        # from the slicer takes that path, because the file goes to internal
+        # storage and no 3MF can be fetched.
+        archive_dir = resolve_archive_dir(archive)
 
         # Save timelapse - use thread pool to avoid blocking event loop
         # (timelapse files can be 100MB+, sync write blocks for seconds).
@@ -1629,6 +1823,10 @@ class ArchiveService:
                 archive_id,
             )
             return False
+        # Created only once the name has been vetted, so a rejected filename
+        # leaves nothing behind. A no-3MF archive has never had a directory of
+        # its own, and the timelapse can be the first thing to want one.
+        await asyncio.to_thread(lambda: timelapse_file.parent.mkdir(parents=True, exist_ok=True))
         await asyncio.to_thread(timelapse_file.write_bytes, timelapse_data)
 
         # Update archive record
@@ -1712,7 +1910,7 @@ async def _convert_timelapse_to_mp4(archive_id: int, source_path: Path) -> None:
             logger.warning(
                 "Timelapse conversion failed for archive %s: %s",
                 archive_id,
-                stderr.decode()[-500:],
+                summarize_ffmpeg_stderr(stderr) or NO_FFMPEG_OUTPUT,
             )
             if mp4_path.exists():
                 mp4_path.unlink()

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 716 - 54
backend/app/services/bambu_ftp.py


+ 507 - 51
backend/app/services/bambu_mqtt.py

@@ -22,6 +22,8 @@ from datetime import datetime, timezone
 import paho.mqtt.client as mqtt
 
 from backend.app.services.hms_actions import HMSAction, get_actions_for_error_code
+from backend.app.services.hms_errors import describe_fault
+from backend.app.utils.ams_drying import ACTIVE_DRY_STATUSES
 
 logger = logging.getLogger(__name__)
 
@@ -40,12 +42,6 @@ _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
@@ -115,6 +111,34 @@ def normalize_am_unit_id(ams_id: int) -> int:
     return A2L_LITE_NORMALIZED_AMS_ID if ams_id == A2L_LITE_PHYSICAL_AMS_ID else ams_id
 
 
+def wire_tray_color(tray_color: str | None) -> str:
+    """Normalise a colour to the form AMS firmware actually parses: UPPERCASE hex.
+
+    P1S firmware 01.10.00.00 parses every lowercase hex letter in ``tray_color``
+    as a zero, and does it silently: the command response echoes the value you
+    sent and reports ``result: "success"``, so only the next AMS push shows what
+    was really stored. Measured on the reporter's machine (#2987), where the
+    spool's own ``rgba`` is stored lowercase and went out verbatim:
+
+        sent 09ff00ff  ->  AMS reports 09000000
+        sent ff5100ff  ->  AMS reports 00510000
+        sent 090000FF  ->  AMS reports 090000FF
+
+    A mangled colour is not merely cosmetic. The auto-unlink sweep compares the
+    tray against the spool it is assigned to, so the tray Bambuddy just wrote no
+    longer matches the spool that asked for it and the assignment is deleted
+    seconds after being made -- and re-assigning through the slot modal writes
+    the mangled colour back, because the modal seeds itself from the tray.
+
+    Applied here, at the one place the command is built, rather than in each of
+    the four callers: a caller that forgets is exactly how this arrived.
+
+    A leading ``#`` is stripped -- the wire format carries bare hex -- and a
+    blank stays blank, which is how a slot is cleared.
+    """
+    return (tray_color or "").strip().lstrip("#").upper()
+
+
 def a2l_lite_wire_ids(ams_id: int, tray_id: int) -> tuple[int, int, int] | None:
     """Translate a normalised A2L slot back to the physical wire form.
 
@@ -630,7 +654,13 @@ class HMSError:
     attr: int  # Attribute value for constructing wiki URL
     module: int
     severity: int  # 1=fatal, 2=serious, 3=common, 4=info
-    message: str = ""
+    # The bundled catalogue's sentence for this fault, resolved once here so
+    # every surface that reports it — the status response, the WebSocket
+    # broadcast, the completion payload, notifications — says the same thing.
+    # None when the catalogue does not cover the code; `describe_fault` documents
+    # the lookup and why the lossy `hms[]` collapse is kept as it was.
+    # Replaces a `message` field that was never set or read anywhere.
+    description: str | None = None
     # User-facing remediation actions from the bundled HMS catalog (e.g. "RESUME_PRINTING",
     # "CHECK_ASSISTANT"). Defaults to an empty list rather than None so the field always
     # satisfies HMSErrorResponse.actions: list[str] — a future code path that builds an
@@ -708,16 +738,74 @@ class FilaSwitchState:
     AMS and the printer's extruders. When installed, the AMS no longer has a
     fixed extruder assignment — any slot can be routed to any extruder via the
     track switch. Detected from print.device.fila_switch in MQTT.
+
+    The switch has two inlets (In-A, In-B) and two outlets (Out-A, Out-B), and
+    can pair any inlet with any outlet. Which AMS sits on which *inlet* is the
+    stable, operator-visible relationship — it is set on the printer's "Manual
+    AMS Setup" screen and read back from AMS ``info`` bits 24-27, not from here.
+
+    Field semantics below are taken from BambuStudio's own parser
+    (``DevFilaSwitch::ParseFilaSwitchInfo``), not inferred.
     """
 
     installed: bool = False
-    # in[track] = currently loaded slot for that track (-1 = empty). The slot
-    # value is reported as observed in MQTT (treated as a global tray ID).
+    # Raw ``in`` array, as it arrives. **Index 0 is In-B and index 1 is In-A** —
+    # the arrays are ordered B-then-A, which is the opposite of how they read.
+    # Each value is snow-encoded: bits 8-15 = AMS id, bits 0-7 = slot. -1 = the
+    # inlet is empty. Use `inlet_slot()` rather than indexing this directly.
     in_slots: list[int] = field(default_factory=list)
-    # out[track] = extruder this track terminates at (0 = right/main, 1 = left)
+    # Raw ``out`` array, same B-then-A order. out[i] = the extruder that *outlet*
+    # terminates at (0 = right/main, 1 = left/deputy), or 0xE when unset. Note
+    # this is the outlet's static wiring, NOT the live inlet→outlet route: which
+    # inlet is currently paired with which outlet is not reported at all.
     out_extruders: list[int] = field(default_factory=list)
-    stat: int = 0  # status flags (0 = idle)
-    info: int = 0  # info flags
+    stat: int = 0  # CaliStatus: 0 = idle, 1 = calibration stepping
+    info: int = 0  # bit 0 = inlet has filament
+
+    def inlet_slot(self, inlet: str) -> tuple[int, int] | None:
+        """Decode ``in`` for inlet ``"A"`` or ``"B"`` into ``(ams_id, slot)``.
+
+        Returns None when the inlet is empty, unreported, or ``inlet`` is not
+        one of A/B.
+        """
+        index = {"A": 1, "B": 0}.get(inlet.upper())
+        if index is None or index >= len(self.in_slots):
+            return None
+        raw = self.in_slots[index]
+        if raw < 0:
+            return None
+        return (raw >> 8) & 0xFF, raw & 0xFF
+
+
+# ``snow``/``spre``/``star`` all use this sentinel for "nothing here". Studio
+# only special-cases it on single-extruder machines, but 0xFFFF decodes to AMS
+# 255 slot 255 and slot 255 is not a real slot on any machine, so treating it
+# as empty everywhere is strictly safer than reading it as the external spool.
+_EXTRUDER_SLOT_EMPTY = 0xFFFF
+
+
+@dataclass
+class ExtruderSlot:
+    """Which AMS slot an extruder is currently fed from.
+
+    Parsed from ``print.device.extruder.info[i]`` — ``snow`` is snow-encoded
+    exactly like ``fila_switch.in`` (bits 8-15 = AMS id, bits 0-7 = slot), and
+    bit 1 of ``info`` says whether the extruder actually holds filament. Field
+    semantics from BambuStudio's ``DevExtruderSystem::ParseExtruderInfo``.
+
+    ``state.tray_now`` cannot answer this: it is a single value for the whole
+    printer, so on a dual-nozzle machine with both hotends loaded it names only
+    one of them. Unloading a specific slot needs to know which extruder is
+    holding it, which is what this is for.
+    """
+
+    ams_id: int | None = None
+    slot_id: int | None = None
+    has_filament: bool = False
+
+    def holds(self, ams_id: int, slot_id: int) -> bool:
+        """True when this extruder is fed from exactly ``(ams_id, slot_id)``."""
+        return self.ams_id == ams_id and self.slot_id == slot_id
 
 
 @dataclass
@@ -793,7 +881,12 @@ class PrinterState:
     wifi_signal: int | None = None  # WiFi signal strength in dBm
     wired_network: bool = False  # Ethernet connection detected (home_flag bit 18)
     door_open: bool = False  # Enclosure door open (home_flag bit 23; models with a door sensor: X1/X1C/X1E/X2D/P2S/H2*)
-    # Nozzle hardware info (for dual nozzle printers, index 0 = left, 1 = right)
+    # Nozzle hardware info. Indexed by EXTRUDER id: [0] is the RIGHT hotend and
+    # [1] the left, measured 2026-08-27 on an H2D fitted with 0.4 left / 0.6
+    # right. (The legacy parser below writes left -> [0], but it only ever runs
+    # for single-nozzle printers -- every dual-nozzle model reports
+    # device.nozzle.info instead.) Read it through services.slot_nozzle rather
+    # than indexing it directly.
     nozzles: list = field(default_factory=lambda: [NozzleInfo(), NozzleInfo()])
     # AI detection and print options
     print_options: PrintOptions = field(default_factory=PrintOptions)
@@ -839,6 +932,16 @@ class PrinterState:
     # Filament Track Switch (FTS) accessory — when installed, AMS info reports
     # bits 8-11 = 0xE (uninitialized) because routing is dynamic. See #1162.
     fila_switch: "FilaSwitchState" = field(default_factory=lambda: FilaSwitchState())
+    # Per-AMS FTS inlet binding: {ams_id: "A" | "B"}. Which of the switch's two
+    # filament inlets an AMS is plumbed into, as set on the printer's "Manual AMS
+    # Setup" screen. Only populated when an FTS is installed — without one an AMS
+    # is bound to an extruder instead and this stays empty. See FilaSwitchState.
+    ams_switch_inlet: dict = field(default_factory=dict)
+    # Which AMS slot each extruder is fed from: {extruder_id: ExtruderSlot}.
+    # Only populated by printers that report ``device.extruder.info`` (H2/X2
+    # series). Empty elsewhere, which every reader has to tolerate — see
+    # ExtruderSlot for why tray_now cannot stand in for it.
+    extruder_slots: dict = field(default_factory=dict)
     # Plate dispatched by Bambuddy for the current print. Some firmware versions
     # (P1S 01.10.00.00) only put the .3mf filename in print.gcode_file, so the
     # regex used to derive the plate number from the path always falls back to
@@ -1051,6 +1154,17 @@ class BambuMQTTClient:
     # Class-level cache: serial_number -> False when request topic is known unsupported.
     # Persists across client instances so reconnects don't re-trigger failed subscriptions.
     _request_topic_cache: dict[str, bool] = {}
+    # serial_number -> consecutive disconnects seen shortly after subscribing to
+    # the request topic. A SUBACK failure is the broker answering the question;
+    # a disconnect is only circumstantial, and any drop inside the window looks
+    # identical -- a network blip, the printer rebooting, the container being
+    # stopped mid-probe. Latching on the first one costs ams_mapping capture for
+    # the rest of the process on a printer that supports it perfectly well
+    # (#2953). Require the drop to repeat before believing it; a printer that
+    # really does refuse the topic answers the same way every time and pays one
+    # extra reconnect for it.
+    _request_topic_probe_failures: dict[str, int] = {}
+    _REQUEST_TOPIC_PROBE_LIMIT: int = 2
     # Counter for generating unique MQTT client IDs across instances.
     _client_instance_counter: int = 0
 
@@ -1078,6 +1192,7 @@ class BambuMQTTClient:
         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,
+        on_fts_inlet_change: Callable[[int, str], None] | None = None,
     ):
         self.ip_address = ip_address
         self.serial_number = serial_number
@@ -1089,6 +1204,9 @@ class BambuMQTTClient:
         self.on_print_start = on_print_start
         self.on_print_complete = on_print_complete
         self.on_ams_change = on_ams_change
+        # Fired when an AMS is moved to the switch's other inlet, which changes
+        # the nozzle it feeds and so invalidates its slots' K-profile bindings.
+        self.on_fts_inlet_change = on_fts_inlet_change
         self.on_layer_change = on_layer_change
         # #2547: fired when `mc_percent` advances during a running print.
         # `on_layer_change` stops firing the instant the final layer starts, so
@@ -1230,12 +1348,35 @@ class BambuMQTTClient:
         # Value: {"nozzle": str, "event": asyncio.Event, "profiles": list | None}.
         self._sequence_id: int = 0
         self._pending_kprofile_requests: dict[str, dict] = {}
+        # The printer's calibration table, one bucket per nozzle diameter.
+        #
+        # An extrusion_cali_get response is the complete table for *one* nozzle
+        # size, and the printer answers whoever asks — including BambuStudio,
+        # whose queries land on the same report topic we subscribe to. Assigning
+        # each response straight to state.kprofiles therefore let any single
+        # answer stand for the whole printer: a GitHub backup probing
+        # 0.2/0.4/0.6/0.8 in turn finished on 0.8, which holds no profiles on a
+        # 0.4+0.6 machine, and left the list empty until something refilled it.
+        # Measured on the maintainer's H2 on 2026-08-25, and visible on the AMS
+        # card because H2-series trays carry no `k` of their own — the slot's
+        # K value is resolved from cali_idx against exactly this list.
+        #
+        # Keyed by diameter so a response only ever replaces the bucket it
+        # actually describes; state.kprofiles is then the union across buckets.
+        # An empty answer for a nozzle the printer doesn't have empties that
+        # bucket alone.
+        self._kprofiles_by_nozzle: dict[str, list] = {}
         # Acks for K-profile *writes* (extrusion_cali_set / extrusion_cali_del),
         # keyed by the sequence_id we sent. The printer echoes it back, measured
         # on both an X1C and an H2D (#2718). Filled by the MQTT thread, drained
         # by await_cali_ack.
         self._pending_cali_acks: dict[str, dict | None] = {}
 
+        # Identifies the one project_file *we* dispatched, so its echo on the
+        # topic can be told apart from a slicer's. One-shot: consumed by the
+        # first frame that matches. See _project_file_key.
+        self._own_project_file_key: str | None = None
+
         # Xcam hold timers - OrcaSlicer pattern: ignore incoming data for 3 seconds after command
         # Key: module_name, Value: timestamp when command was sent
         self._xcam_hold_start: dict[str, float] = {}
@@ -1655,6 +1796,7 @@ class BambuMQTTClient:
                     )
                     self._request_topic_confirmed = True
                     BambuMQTTClient._request_topic_cache[self.serial_number] = True
+                    BambuMQTTClient._request_topic_probe_failures.pop(self.serial_number, None)
             self._request_topic_sub_mid = None
             self._request_topic_sub_time = 0.0
 
@@ -1711,13 +1853,30 @@ class BambuMQTTClient:
             self._request_topic_sub_time > 0
             and not self._request_topic_confirmed
             and time.time() - self._request_topic_sub_time < 10.0
+            # A disconnect we asked for says nothing about the subscription.
+            and self._disconnection_event is None
         ):
-            logger.warning(
-                "[%s] Disconnected shortly after request topic subscription. Disabling request topic for this printer.",
-                self.serial_number,
-            )
-            self._request_topic_supported = False
-            BambuMQTTClient._request_topic_cache[self.serial_number] = False
+            failures = BambuMQTTClient._request_topic_probe_failures.get(self.serial_number, 0) + 1
+            BambuMQTTClient._request_topic_probe_failures[self.serial_number] = failures
+            if failures >= BambuMQTTClient._REQUEST_TOPIC_PROBE_LIMIT:
+                logger.warning(
+                    "[%s] Disconnected shortly after request topic subscription %d times. "
+                    "Disabling request topic for this printer — ams_mapping capture from "
+                    "slicer-initiated prints is unavailable, and their filament will be "
+                    "attributed from the printer's own tray reporting instead.",
+                    self.serial_number,
+                    failures,
+                )
+                self._request_topic_supported = False
+                BambuMQTTClient._request_topic_cache[self.serial_number] = False
+            else:
+                logger.info(
+                    "[%s] Disconnected shortly after request topic subscription (%d/%d). "
+                    "Retrying it on the next connection before giving up.",
+                    self.serial_number,
+                    failures,
+                    BambuMQTTClient._REQUEST_TOPIC_PROBE_LIMIT,
+                )
         self._request_topic_sub_mid = None
         self._request_topic_sub_time = 0.0
 
@@ -1826,16 +1985,94 @@ class BambuMQTTClient:
             # Diagnostic for #1162 follow-up (X2D + FTS routing): when a
             # slicer-launched project_file passes through the request topic,
             # log the full payload so we can diff Studio's field set against
-            # ours. We pin our own sequence_id to "20000" (line ~3195), so
-            # any other value means the command came from Studio/Orca, not
-            # from us.
-            if print_data.get("sequence_id") != "20000":
+            # ours.
+            #
+            # This used to read `sequence_id != "20000"`, on the belief that
+            # 20000 was ours alone. It is not: 20000 is the slicer convention
+            # Bambuddy adopted -- bind_server documents the slicer sending it
+            # during detect, and measured on the wire OrcaSlicer dispatched
+            # 20000 then 20001 while BambuStudio was on 20009/20010, both
+            # counting up from the same base. So the test swallowed whichever
+            # slicer dispatch happened to land on 20000, which on a fresh
+            # slicer start is the first one. Match our own dispatch instead.
+            if self._project_file_key(print_data) == self._own_project_file_key:
+                self._own_project_file_key = None
+            else:
                 logger.info(
                     "[%s] External project_file payload: %s",
                     self.serial_number,
                     json.dumps(print_data),
                 )
 
+    def _capture_report_project_file(self, print_data: dict) -> None:
+        """Read a print's destination off a ``project_file`` *response* (#1820).
+
+        ``_handle_request_message`` only ever sees the request topic, so a print
+        started from the printer's own touchscreen -- which publishes nothing --
+        left ``current_project_url`` at None, and the storage verdict fell
+        through to the ``sdcard`` fallback for the one case it was written for.
+        On an H2S that flag is True (its "card" is the internal eMMC), so the
+        verdict came back reachable and the ~110-connection sweep ran in full.
+
+        The printer does announce it: an unsolicited ``project_file`` response
+        on the report topic, ~2 s before ``gcode_state`` reaches PREPARE,
+        carrying ``file:///userdata/model/history/<name>.gcode.3mf``.
+
+        This also covers an install nobody had in view: some brokers refuse the
+        request-topic subscription, and on those no print of any kind has ever
+        populated the field.
+
+        Both kinds of ``project_file`` on this topic are read -- the printer's
+        echo of a dispatch and a screen start -- because both name the
+        destination in ``url``, which is the only thing the verdict wants. What
+        this must NOT do is reuse ``_handle_request_message``'s "External
+        project_file payload" diagnostic: our own dispatch is echoed on *both*
+        topics, the request-topic echo arrives first and clears
+        ``_own_project_file_key``, so by the time this frame lands the key is
+        already None and every Bambuddy-started print would log itself as
+        someone else's.
+        """
+        # Same shape as _handle_request_message: the frame is whatever the
+        # printer put on the wire, and this is the first thing to touch it.
+        if not isinstance(print_data, dict) or print_data.get("command") != "project_file":
+            return
+        # A refused dispatch names a file that was never written. Acting on it
+        # would pin an archive on a destination nothing ever went to.
+        if print_data.get("result") != "SUCCESS":
+            return
+        url = print_data.get("url")
+        if not isinstance(url, str) or not url:
+            return
+        if self.state.current_project_url != url:
+            logger.info(
+                "[%s] Print destination from the report topic: %s",
+                self.serial_number,
+                url,
+            )
+        self.state.current_project_url = url
+        self.state.last_project_url = url
+        # On a screen start this frame is the only place the mapping appears --
+        # no slicer ever sent one. Fill a gap only: when the request topic
+        # already captured this print's mapping that copy is the slicer's own,
+        # and the echo can arrive without the field at all.
+        if self._captured_ams_mapping is None and isinstance(print_data.get("ams_mapping"), list):
+            self._captured_ams_mapping = print_data["ams_mapping"]
+            logger.info(
+                "[%s] Captured ams_mapping from print response: %s",
+                self.serial_number,
+                self._captured_ams_mapping,
+            )
+
+    @staticmethod
+    def _project_file_key(print_data: dict) -> str:
+        """Identity of a project_file dispatch, for telling ours from a slicer's.
+
+        Sequence id alone cannot do it -- every slicer counts up from the same
+        20000 -- so this also carries the file and its destination, which differ
+        between any two real dispatches.
+        """
+        return "|".join(str(print_data.get(field, "")) for field in ("sequence_id", "file", "url", "subtask_name"))
+
     def _debug_on_change(self, key: str, value: object, msg: str, *args: object) -> None:
         """``logger.debug``, but only when ``value`` differs from the last call for ``key``.
 
@@ -1937,6 +2174,11 @@ class BambuMQTTClient:
         if "print" in payload:
             print_data = payload["print"]
 
+            # Before anything reads the state: this is where a touchscreen-
+            # started print announces where its file lives, and the print-start
+            # handler asks ~2 s later (#1820).
+            self._capture_report_project_file(print_data)
+
             # Check if xcam is nested inside print data
             if "xcam" in print_data:
                 logger.debug("[%s] Found xcam inside print data: %s", self.serial_number, print_data["xcam"])
@@ -1986,6 +2228,12 @@ class BambuMQTTClient:
                         self._is_dual_nozzle = True
                         logger.info("[%s] Detected dual-nozzle printer from device.extruder.info", self.serial_number)
 
+            # Must run before _handle_ams_data: the per-AMS inlet binding is read
+            # out of the AMS info bits, but only means anything once we know a
+            # switch is installed. Parsing them the other way round would lose
+            # the binding on every frame where the two arrive together.
+            self._parse_fila_switch(print_data)
+
             # Handle AMS data that comes inside print key
             if "ams" in print_data:
                 try:
@@ -2635,6 +2883,66 @@ class BambuMQTTClient:
                     )
                 self._has_a2l_am_unit = True
 
+    def _parse_fila_switch(self, data: dict) -> None:
+        """Read the Filament Track Switch block out of a print payload — #1162.
+
+        Presence of ``device.fila_switch`` means the accessory is installed. Kept
+        separate from the rest of the state update because ``_handle_ams_data``
+        needs the answer before it parses the AMS info bits, and that runs first.
+        """
+        if not isinstance(data.get("device"), dict):
+            return
+        fs_data = data["device"].get("fila_switch")
+        if not isinstance(fs_data, dict):
+            return
+        in_raw = fs_data.get("in")
+        out_raw = fs_data.get("out")
+        self.state.fila_switch = FilaSwitchState(
+            installed=True,
+            in_slots=list(in_raw) if isinstance(in_raw, list) else [],
+            out_extruders=list(out_raw) if isinstance(out_raw, list) else [],
+            stat=int(fs_data.get("stat", 0) or 0),
+            info=int(fs_data.get("info", 0) or 0),
+        )
+
+    def _parse_extruder_slots(self, data: dict) -> None:
+        """Read which AMS slot each extruder is fed from — ``device.extruder.info``.
+
+        Absent on printers that do not report the block, in which case the
+        previous answer is kept rather than cleared: a partial payload carrying
+        only temperatures must not look like "both hotends are now empty".
+        """
+        device = data.get("device")
+        if not isinstance(device, dict):
+            return
+        info = device.get("extruder", {}).get("info") if isinstance(device.get("extruder"), dict) else None
+        if not isinstance(info, list) or not info:
+            return
+
+        slots: dict[int, ExtruderSlot] = {}
+        for entry in info:
+            if not isinstance(entry, dict) or "id" not in entry:
+                continue
+            try:
+                ext_id = int(entry["id"])
+                snow = int(entry.get("snow", _EXTRUDER_SLOT_EMPTY))
+                flags = int(entry.get("info", 0) or 0)
+            except (TypeError, ValueError):
+                continue
+            if snow == _EXTRUDER_SLOT_EMPTY or snow < 0:
+                ams_id = slot_id = None
+            else:
+                ams_id = (snow >> 8) & 0xFF
+                slot_id = snow & 0xFF
+            slots[ext_id] = ExtruderSlot(
+                ams_id=ams_id,
+                slot_id=slot_id,
+                has_filament=bool(flags & 0b10),
+            )
+
+        if slots:
+            self.state.extruder_slots = slots
+
     def _handle_ams_data(self, ams_data):
         """Handle AMS data changes for Spoolman integration.
 
@@ -3143,13 +3451,25 @@ class BambuMQTTClient:
         # BambuStudio DevFilaSystem.cpp parses info as hex string:
         #   type_id    = get_flag_bits(info, 0, 4)   // bits 0-3: AMS type
         #   extruder_id = get_flag_bits(info, 8, 4)  // bits 8-11: extruder assignment
+        #   bind_switch_in = get_flag_bits(info, 24, 4)  // bits 24-27: FTS inlet
         # where get_flag_bits uses std::stoull(str, nullptr, 16) — hex parsing.
-        # extruder_id: 0=right/main, 1=left/deputy, 0xE=uninitialized (skip)
+        # extruder_id: 0=right/main, 1=left/deputy, 0xE=routing is not fixed
+        #
+        # 0xE does not mean "broken". On a Filament Track Switch machine it is the
+        # normal steady state: the AMS is bound to a switch *inlet* rather than to
+        # one extruder, and reaches both nozzles through it. Bits 24-27 then name
+        # that inlet — 0 = In-B, 1 = In-A (BambuStudio's SwitchPos enum, which is
+        # ordered B-then-A). Without an FTS, 0xE really is an uninitialised unit
+        # and bits 24-27 carry nothing, which is why the inlet read is gated on
+        # the switch being installed.
         #
         # Use merged_ams (not ams_list) to avoid partial MQTT updates overwriting
         # the full map. Merge into existing map to preserve entries from prior updates.
 
+        fts_installed = self.state.fila_switch.installed
+        inlet_moves: list[tuple[int, str]] = []
         ams_extruder_map = dict(self.state.ams_extruder_map) if self.state.ams_extruder_map else {}
+        ams_switch_inlet = dict(self.state.ams_switch_inlet) if self.state.ams_switch_inlet else {}
         for ams_unit in merged_ams:
             ams_id = ams_unit.get("id")
             info = ams_unit.get("info")
@@ -3160,7 +3480,32 @@ class BambuMQTTClient:
                     # Extract 4 bits starting at bit 8 for extruder assignment
                     extruder_id = (info_val >> 8) & 0xF
                     if extruder_id == 0xE:
-                        # 0xE = uninitialized AMS, skip
+                        if fts_installed:
+                            inlet = {0: "B", 1: "A"}.get((info_val >> 24) & 0xF)
+                            if inlet is not None:
+                                previous = ams_switch_inlet.get(str(ams_id))
+                                ams_switch_inlet[str(ams_id)] = inlet
+                                self._debug_on_change(
+                                    f"ams_inlet:{ams_id}",
+                                    inlet,
+                                    "[%s] AMS %s info=0x%s -> FTS inlet %s",
+                                    self.serial_number,
+                                    ams_id,
+                                    info,
+                                    inlet,
+                                )
+                                if previous is not None and previous != inlet:
+                                    # Only a genuine move, never the first sighting:
+                                    # re-applying K-profiles on every reconnect would
+                                    # fight a binding the operator set deliberately.
+                                    logger.info(
+                                        "[%s] AMS %s moved to FTS inlet %s (was %s)",
+                                        self.serial_number,
+                                        ams_id,
+                                        inlet,
+                                        previous,
+                                    )
+                                    inlet_moves.append((int(ams_id), inlet))
                         continue
                     ams_extruder_map[str(ams_id)] = extruder_id
                     self._debug_on_change(
@@ -3178,6 +3523,11 @@ class BambuMQTTClient:
             self.state.raw_data["ams_extruder_map"] = ams_extruder_map
             self.state.ams_extruder_map = ams_extruder_map
             logger.debug("[%s] ams_extruder_map: %s", self.serial_number, ams_extruder_map)
+        if ams_switch_inlet:
+            self.state.ams_switch_inlet = ams_switch_inlet
+        for moved_ams_id, moved_inlet in inlet_moves:
+            if self.on_fts_inlet_change:
+                self.on_fts_inlet_change(moved_ams_id, moved_inlet)
 
         # Extract drying status from info hex string and dry_sf_reason per AMS unit
         # BambuStudio DevFilaSystem.cpp parses info bits:
@@ -3240,7 +3590,7 @@ class BambuMQTTClient:
             # 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:
+            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.
@@ -3931,20 +4281,11 @@ class BambuMQTTClient:
                 if "cur" in ext_data:
                     logger.debug("[%s] device.extruder.cur: %s", self.serial_number, ext_data["cur"])
 
-        # Filament Track Switch (FTS) detection — #1162. Presence of
-        # device.fila_switch in MQTT means the FTS accessory is installed.
-        if "device" in data and isinstance(data.get("device"), dict):
-            fs_data = data["device"].get("fila_switch")
-            if isinstance(fs_data, dict):
-                in_raw = fs_data.get("in")
-                out_raw = fs_data.get("out")
-                self.state.fila_switch = FilaSwitchState(
-                    installed=True,
-                    in_slots=list(in_raw) if isinstance(in_raw, list) else [],
-                    out_extruders=list(out_raw) if isinstance(out_raw, list) else [],
-                    stat=int(fs_data.get("stat", 0) or 0),
-                    info=int(fs_data.get("info", 0) or 0),
-                )
+        # Also parsed earlier in _process_message, because _handle_ams_data needs
+        # it first. Repeated here so _update_state stays a complete "absorb this
+        # payload" step for any other caller; re-parsing the same block is free.
+        self._parse_fila_switch(data)
+        self._parse_extruder_slots(data)
 
         if "bed_temper" in data:
             temps["bed"] = float(data["bed_temper"])
@@ -4461,6 +4802,7 @@ class BambuMQTTClient:
                                 actions=actions,
                                 job_id=self.state.subtask_id,
                                 full_code=full_code,
+                                description=describe_fault(full_code),
                             )
                         )
             self._apply_mqtt_verify_state(verify_failed)
@@ -4536,6 +4878,7 @@ class BambuMQTTClient:
                                     # print_error is already 32-bit — `f"{print_error:08X}"`
                                     # is the firmware's matching key with no truncation.
                                     full_code=f"{print_error:08X}",
+                                    description=describe_fault(f"{print_error:08X}"),
                                 )
                             )
 
@@ -5113,7 +5456,16 @@ class BambuMQTTClient:
             # Include HMS errors for failure reason detection
             hms_errors_data = (
                 [
-                    {"code": e.code, "attr": e.attr, "module": e.module, "severity": e.severity}
+                    {
+                        "code": e.code,
+                        "attr": e.attr,
+                        "module": e.module,
+                        "severity": e.severity,
+                        # Carried so the queue's failure reason quotes the same
+                        # sentence the status response and the broadcast do,
+                        # rather than resolving the code a fourth time (#2926).
+                        "description": e.description,
+                    }
                     for e in self.state.hms_errors
                 ]
                 if self.state.hms_errors
@@ -5753,6 +6105,9 @@ class BambuMQTTClient:
                         command["print"]["nozzle_mapping"] = resolved
 
             logger.info("[%s] Sending print command: %s", self.serial_number, json.dumps(command))
+            # Remember this dispatch so its echo on the topic is recognised as
+            # ours rather than logged as a slicer's.
+            self._own_project_file_key = self._project_file_key(command["print"])
             self._client.publish(self.topic_publish, json.dumps(command), qos=1)
             # Record what we dispatched so /cover can pick the right plate
             # thumbnail even when the printer's gcode_file echo is just the
@@ -6193,6 +6548,33 @@ class BambuMQTTClient:
                     logger.debug("Failed to parse K-profile from broadcast: %s", e)
         return profiles
 
+    def _store_kprofiles(self, profiles: list, response_nozzle: str | None) -> None:
+        """File one calibration-table response under its nozzle diameter.
+
+        ``response_nozzle`` names the table the printer just sent, so that
+        bucket is replaced wholesale and every other one is left alone. When
+        the envelope carries no diameter, fall back to the diameters the parsed
+        profiles claim for themselves — and if there are none of those either,
+        keep what we have rather than dropping a table we cannot attribute.
+
+        ``state.kprofiles`` stays a flat list because that is what its readers
+        expect; the three assign paths already filter it by ``nozzle_diameter``
+        and were quietly finding nothing whenever the last response happened to
+        be for a different nozzle.
+        """
+        buckets: dict[str, list] = {}
+        if response_nozzle:
+            buckets[str(response_nozzle)] = list(profiles)
+        else:
+            for profile in profiles:
+                buckets.setdefault(str(profile.nozzle_diameter), []).append(profile)
+        if not buckets:
+            return
+        self._kprofiles_by_nozzle.update(buckets)
+        self.state.kprofiles = [
+            kp for nozzle in sorted(self._kprofiles_by_nozzle) for kp in self._kprofiles_by_nozzle[nozzle]
+        ]
+
     def _handle_kprofile_response(self, data: dict):
         """Handle K-profile response from printer."""
         response_nozzle = data.get("nozzle_diameter")
@@ -6240,11 +6622,20 @@ class BambuMQTTClient:
             return
 
         profiles = self._parse_kprofile_entries(filaments, response_nozzle, log_errors=request is not None)
-        self.state.kprofiles = profiles
+        self._store_kprofiles(profiles, response_nozzle)
 
         if request is None:
             # Unsolicited broadcast with nothing in flight: state is refreshed,
-            # nobody to wake.
+            # nobody to wake. Worth a line — this is the printer answering
+            # somebody else (BambuStudio queries the same report topic), and
+            # until it was bucketed by nozzle it was also the quietest way for
+            # the AMS card's K values to change underneath us.
+            logger.debug(
+                "[%s] Adopted unsolicited K-profile table: nozzle=%s, %d profiles",
+                self.serial_number,
+                response_nozzle or "?",
+                len(profiles),
+            )
             return
 
         logger.info("[%s] Got %s K-profiles for nozzle=%s", self.serial_number, len(profiles), response_nozzle)
@@ -6982,7 +7373,16 @@ class BambuMQTTClient:
             tray_id: Global tray ID — 0..15 for AMS slots, 254 for external spool
                 (single-external printers and Ext-L on dual-nozzle H2D),
                 255 for Ext-R on dual-nozzle H2D.
-            extruder_id: Unused - kept for API compatibility
+            extruder_id: Which hotend to feed (0 = right/main, 1 = left/deputy).
+                Sent only when given, matching BambuStudio: ``extruder_id`` is
+                an optional field on ``ams_change_filament``
+                (``DeviceManager::command_ams_change_filament``) and Studio
+                omits it unless a Filament Track Switch is installed. Without a
+                switch the firmware derives the hotend from the AMS's own
+                extruder binding and an explicit value is redundant; *with* one
+                every AMS reports 0xE and is bound to a switch inlet instead, so
+                the firmware has nothing to derive from and the load silently
+                does nothing until we name the hotend.
 
         Returns:
             True if command was sent, False otherwise
@@ -7037,6 +7437,8 @@ class BambuMQTTClient:
                 "tar_temp": tar_temp,
             }
         }
+        if extruder_id is not None:
+            command["print"]["extruder_id"] = int(extruder_id)
 
         command_json = json.dumps(command)
         logger.info("[%s] Publishing ams_change_filament command: %s", self.serial_number, command_json)
@@ -7051,8 +7453,21 @@ class BambuMQTTClient:
 
         return True
 
-    def ams_unload_filament(self) -> bool:
-        """Unload the currently loaded filament.
+    def ams_unload_filament(self, tray_id: int | None = None) -> bool:
+        """Unload filament, optionally naming the slot to unload.
+
+        Args:
+            tray_id: Global tray ID of the slot being unloaded. When given, the
+                command is addressed to that slot's AMS and is only sent if an
+                extruder is actually fed from it — BambuStudio does the same
+                (``StatusPanel::on_ams_unload`` walks the extruders and sends
+                nothing when none matches). When omitted, the pre-existing
+                behaviour is kept: unload whatever ``tray_now`` names.
+
+        ``tray_now`` is a single value for the whole printer, so on a dual-nozzle
+        machine with both hotends loaded it names only one of them and an
+        unaddressed unload picks that one regardless of which slot the operator
+        clicked. Passing the slot is what makes the two hotends distinguishable.
 
         Returns:
             True if command was sent, False otherwise
@@ -7063,15 +7478,54 @@ class BambuMQTTClient:
 
         # Get the currently loaded tray info
         tray_now = self.state.tray_now
-        logger.info("[%s] Unload requested, tray_now=%s", self.serial_number, tray_now)
+        source_tray = tray_now if tray_id is None else tray_id
+        logger.info("[%s] Unload requested, tray_now=%s, tray_id=%s", self.serial_number, tray_now, tray_id)
 
         # Determine source ams_id for the unload command
-        if tray_now == 255 or tray_now == 254:
+        if source_tray == 255 or source_tray == 254:
             ams_id = 255  # No filament or external spool
-        elif (_a2l := a2l_lite_wire_ids(tray_now // 4, tray_now)) is not None:
+        elif (_a2l := a2l_lite_wire_ids(source_tray // 4, source_tray)) is not None:
             ams_id = _a2l[0]  # A2L AMS-Lite: normalised 6 -> physical 16
         else:
-            ams_id = tray_now // 4  # Source AMS
+            ams_id = source_tray // 4  # Source AMS
+
+        # Refuse an addressed unload of a slot no hotend is holding — but only on
+        # a printer that has more than one hotend, which is the only case the
+        # check exists for. With one hotend there is nothing to disambiguate:
+        # tray_now already names the loaded slot exactly, and running the check
+        # anyway would stake unload on `snow` meaning ams*4+slot there too. It
+        # very likely does, but single-nozzle machines do report the block —
+        # BambuStudio has a dedicated branch for `m_total_extder_count == 1` and
+        # an X1C on the maintainer's own network sends `device.extruder` — and
+        # nobody has read a single-nozzle `snow` off the wire. Guessing wrong
+        # would 409 every unload on every X1C, P1S and A1.
+        #
+        # Gated on the runtime flag rather than on len(extruder_slots), which is
+        # rebuilt from each payload's array and would flip the check off for any
+        # frame that carried a short one; and deliberately not on
+        # ``is_dual_nozzle_model``, whose model-name fallback reports at least
+        # one single-nozzle machine as dual (#1386) — the false positive there is
+        # exactly the case this gate exists to keep out.
+        #
+        # The external spool is excluded for a different reason: 254/255 are not
+        # ams*4+slot, so the local-slot arithmetic below cannot describe them.
+        if tray_id is not None and tray_id not in (254, 255) and self._is_dual_nozzle and self.state.extruder_slots:
+            local_slot = _a2l[1] if (_a2l := a2l_lite_wire_ids(tray_id // 4, tray_id)) is not None else tray_id % 4
+            holder = next(
+                (ext for ext, slot in self.state.extruder_slots.items() if slot.holds(ams_id, local_slot)),
+                None,
+            )
+            if holder is None:
+                logger.info(
+                    "[%s] Unload skipped: no extruder is fed from AMS %s slot %s",
+                    self.serial_number,
+                    ams_id,
+                    local_slot,
+                )
+                return False
+            logger.info(
+                "[%s] Unloading AMS %s slot %s from extruder %s", self.serial_number, ams_id, local_slot, holder
+            )
 
         # Command format from BambuStudio traffic capture:
         # - No extruder_id field
@@ -7254,7 +7708,9 @@ class BambuMQTTClient:
                 "tray_info_idx": tray_info_idx,
                 "tray_type": tray_type,
                 "tray_sub_brands": tray_sub_brands,
-                "tray_color": tray_color,
+                # UPPERCASE, always: lowercase hex is silently read as zeros by
+                # P1S firmware and acknowledged as a success (#2987).
+                "tray_color": wire_tray_color(tray_color),
                 "nozzle_temp_min": nozzle_temp_min,
                 "nozzle_temp_max": nozzle_temp_max,
                 "sequence_id": "0",

+ 62 - 10
backend/app/services/camera.py

@@ -17,7 +17,7 @@ import uuid
 from datetime import datetime
 from pathlib import Path
 
-from backend.app.core.logging_filters import redact_url_credentials
+from backend.app.utils.ffmpeg_output import NO_FFMPEG_OUTPUT, summarize_ffmpeg_stderr
 
 logger = logging.getLogger(__name__)
 
@@ -231,7 +231,8 @@ async def create_tls_proxy(target_host: str, target_port: int) -> tuple[int, "as
     rewrites ``127.0.0.1:<proxy_port>`` → ``<target_host>:<target_port>`` in
     client→server data so the printer recognises the stream path.
 
-    Returns ``(local_port, server)``.  Caller must close the server when done.
+    Returns ``(local_port, server)``.  Caller must close it with
+    :func:`close_tls_proxy` when done.
     """
     ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
     ssl_ctx.check_hostname = False
@@ -240,7 +241,20 @@ async def create_tls_proxy(target_host: str, target_port: int) -> tuple[int, "as
     # Filled in after the server socket is created (handler only runs after).
     _local_port: list[int] = [0]
 
+    # Strong references to the in-flight connection handlers (#2968).
+    # ``asyncio.start_server`` wraps the callback in a task and keeps only a
+    # weak reference to it, so a handler still awaiting its two forwarders can
+    # be garbage-collected out from under itself — which is asyncio's
+    # "Task was destroyed but it is pending!", logged at ERROR with a traceback
+    # pointing here and no indication that it is a teardown race rather than a
+    # camera fault. Holding the set also gives close_tls_proxy something to
+    # cancel, so shutdown stops depending on ffmpeg having dropped its end.
+    handlers: set[asyncio.Task] = set()
+
     async def _handle(client_reader: asyncio.StreamReader, client_writer: asyncio.StreamWriter):
+        current = asyncio.current_task()
+        if current is not None:
+            handlers.add(current)
         tls_writer = None
         try:
             tls_reader, tls_writer = await asyncio.wait_for(
@@ -305,7 +319,19 @@ async def create_tls_proxy(target_host: str, target_port: int) -> tuple[int, "as
             )
         except (ConnectionError, OSError, TimeoutError) as e:
             logger.debug("TLS proxy connection to %s:%s failed: %s", target_host, target_port, e)
+        except asyncio.CancelledError:
+            # close_tls_proxy cancelling us at shutdown, which is the only thing
+            # that cancels this task. Swallowing a cancellation is normally
+            # wrong because it hides the request from whoever made it; here we
+            # *are* whoever made it, the cleanup it exists to trigger is in the
+            # finally below, and nothing awaits this task's result. Asyncio's
+            # own done-callback for a connection handler treats a cancelled task
+            # differently from a completed one, so ending in the ordinary way
+            # keeps the teardown on one path across Python versions.
+            pass
         finally:
+            if current is not None:
+                handlers.discard(current)
             for w in (client_writer, tls_writer):
                 if w and not w.is_closing():
                     try:
@@ -315,10 +341,36 @@ async def create_tls_proxy(target_host: str, target_port: int) -> tuple[int, "as
 
     server = await asyncio.start_server(_handle, "127.0.0.1", 0)
     _local_port[0] = server.sockets[0].getsockname()[1]
+    server._bambuddy_proxy_handlers = handlers  # type: ignore[attr-defined]
     logger.debug("TLS proxy for %s:%s listening on 127.0.0.1:%s", target_host, target_port, _local_port[0])
     return _local_port[0], server
 
 
+async def close_tls_proxy(server: "asyncio.Server") -> None:
+    """Shut a :func:`create_tls_proxy` server down without leaving tasks behind.
+
+    ``server.close()`` stops the listener but leaves established connections
+    running, and ``wait_closed()`` is only as deterministic as the peer: it
+    waits for the handlers, and a handler waits for ffmpeg to drop its end of
+    the socket. By the time this is called ffmpeg has already been reaped, so
+    the connection is dead weight — cancelling it is both correct and the only
+    way to guarantee no handler outlives the server that owns it.
+
+    ``Server.close_clients()`` would do this natively, but it landed in Python
+    3.13 and Bambuddy supports 3.10, so the handler set is tracked by hand.
+
+    Safe to call on a plain ``asyncio.Server`` from anywhere else: without the
+    attribute it degrades to the close/wait it replaces.
+    """
+    handlers: set[asyncio.Task] = getattr(server, "_bambuddy_proxy_handlers", set())
+    server.close()
+    for task in list(handlers):
+        task.cancel()
+    if handlers:
+        await asyncio.gather(*list(handlers), return_exceptions=True)
+    await server.wait_closed()
+
+
 def is_chamber_image_model(model: str | None) -> bool:
     """Check if printer uses chamber image protocol instead of RTSP.
 
@@ -692,8 +744,7 @@ async def _capture_camera_frame_bytes_uncoalesced(
 
     ffmpeg = get_ffmpeg_path()
     if not ffmpeg:
-        proxy_server.close()
-        await proxy_server.wait_closed()
+        await close_tls_proxy(proxy_server)
         logger.error("ffmpeg not found for camera frame capture")
         return None
 
@@ -740,9 +791,11 @@ async def _capture_camera_frame_bytes_uncoalesced(
             logger.info("Successfully captured camera frame bytes: %s bytes", len(stdout))
             return stdout
         else:
-            # ffmpeg echoes the RTSP input URL, which carries the access code.
-            stderr_text = redact_url_credentials(stderr.decode()) if stderr else "Unknown error"
-            logger.error("ffmpeg frame bytes capture failed (code %s): %s", process.returncode, stderr_text[:200])
+            # The summariser drops ffmpeg's banner and masks the access code
+            # the RTSP input URL carries; without it this line was 200
+            # characters of build configuration (#2968).
+            stderr_text = summarize_ffmpeg_stderr(stderr) or NO_FFMPEG_OUTPUT
+            logger.error("ffmpeg frame bytes capture failed (code %s): %s", process.returncode, stderr_text)
             return None
 
     except FileNotFoundError:
@@ -754,8 +807,7 @@ async def _capture_camera_frame_bytes_uncoalesced(
     finally:
         if process is not None:
             _active_capture_pids.discard(process.pid)
-        proxy_server.close()
-        await proxy_server.wait_closed()
+        await close_tls_proxy(proxy_server)
 
 
 async def extract_video_last_frame(video_path: Path, output_path: Path) -> bool:
@@ -815,7 +867,7 @@ async def extract_video_last_frame(video_path: Path, output_path: Path) -> bool:
             logger.warning(
                 "ffmpeg failed extracting last frame from %s: %s",
                 video_path,
-                stderr.decode(errors="replace")[:500],
+                summarize_ffmpeg_stderr(stderr) or NO_FFMPEG_OUTPUT,
             )
             return False
         if not output_path.exists() or output_path.stat().st_size == 0:

+ 35 - 1
backend/app/services/design_settings.py

@@ -53,6 +53,10 @@ class DesignOverride(NamedTuple):
     key: str
     value: Any
     printer_coupled: bool
+    # Set for the handful of keys that *define* the picked process preset —
+    # see :data:`_PRESET_DEFINING`. Offered like printer-coupled ones, never
+    # pre-selected, because the user's preset pick has to win over the file.
+    preset_defining: bool = False
 
 
 # Process keys whose sane value depends on the machine, not on the design intent.
@@ -93,6 +97,29 @@ _PRINTER_COUPLED_SUBSTRINGS: tuple[str, ...] = (
 )
 
 
+# Process keys whose value *is* the preset the user picked. "0.08mm High
+# Quality" is not a name with a layer height attached — the layer height is
+# what the preset is, and the same holds for the first layer it starts on.
+#
+# Carrying these from the file would quietly undo an explicit pick: choose the
+# 0.08 preset for a MakerWorld file whose designer moved layer height to 0.2
+# and, with every non-printer-coupled key pre-selected, the slice comes out at
+# 0.2 while the dropdown still reads 0.08. The designer's value stays on offer
+# — a re-slice that genuinely wants the design's layer height is one tick away
+# — but nothing here is applied without the user saying so.
+_PRESET_DEFINING: frozenset[str] = frozenset(
+    {
+        "layer_height",
+        "initial_layer_print_height",
+    }
+)
+
+
+def is_preset_defining(key: str) -> bool:
+    """Whether this key is the identity of the picked process preset."""
+    return key in _PRESET_DEFINING
+
+
 def is_printer_coupled(key: str) -> bool:
     """Whether carrying this process key across printer models is risky."""
     if key in _PRINTER_COUPLED_EXACT:
@@ -159,7 +186,14 @@ def overrides_from_config(config: Any) -> list[DesignOverride]:
             # Listed as changed but absent from the flattened config — nothing
             # to carry. Seen with keys the slicer renamed between versions.
             continue
-        overrides.append(DesignOverride(key=key, value=config[key], printer_coupled=is_printer_coupled(key)))
+        overrides.append(
+            DesignOverride(
+                key=key,
+                value=config[key],
+                printer_coupled=is_printer_coupled(key),
+                preset_defining=is_preset_defining(key),
+            )
+        )
 
     overrides.sort(key=lambda o: o.key)
     return overrides

+ 117 - 0
backend/app/services/drying_preflight.py

@@ -0,0 +1,117 @@
+"""Shared checks run before an AMS drying command is sent.
+
+Both the immediate POST /printers/{id}/drying/start endpoint and the
+scheduler's delayed dispatch go through here, so a run that the immediate
+path would refuse is never silently published by the scheduled path.
+"""
+
+from backend.app.services.printer_manager import drying_screen_only, supports_drying
+
+SCREEN_ONLY_DETAIL = "This printer only supports AMS drying from its own screen"
+UNSUPPORTED_DETAIL = "Drying not supported for this printer model or firmware version"
+
+# Firmware dry_sf_reason codes, as surfaced by the AMS status payload.
+DRY_SF_REASON_MESSAGES = {
+    0: "Printer is busy",
+    1: "Insufficient power: too many AMS drying or external PSU required",
+    2: "AMS is busy",
+    3: "Filament is at the AMS outlet, retract it first",
+    4: "AMS is already starting a drying cycle",
+    5: "Not supported in 2D mode",
+    6: "AMS is already drying",
+    7: "AMS firmware is upgrading",
+    8: "Plug in the external AMS power adapter to start drying",
+}
+
+# Codes 1 and 8 mean a power-supply problem the user has to fix; the rest
+# clear on their own. The frontend splits blocked states the same way.
+POWER_REASON_CODES = frozenset({1, 8})
+
+# Code 3 also needs the user to act, but the fix is retracting filament rather
+# than anything to do with power, so it gets its own token instead of the
+# generic "cannot dry right now" the transient codes share.
+RETRACT_REASON_CODE = 3
+
+WAITING_REASON_POWER = "ams_power_required"
+WAITING_REASON_RETRACT = "ams_retract_filament"
+WAITING_REASON_BLOCKED = "ams_blocked"
+
+
+def check_drying_supported(model: str | None, firmware: str | None, *, require_firmware: bool = True) -> str | None:
+    """Return a message if this printer cannot dry, else None.
+
+    Pass require_firmware=False when there is no live status to read a version
+    from, which leaves only the model check. Dispatch always has a status and
+    judges both.
+    """
+    if drying_screen_only(model):
+        return SCREEN_ONLY_DETAIL
+    if require_firmware and not supports_drying(model, firmware):
+        return UNSUPPORTED_DETAIL
+    return None
+
+
+def find_ams_unit(state, ams_id: int) -> dict | None:
+    """Locate an AMS unit in a live printer status payload."""
+    for unit in (state.raw_data.get("ams") if state else None) or []:
+        try:
+            if int(unit.get("id", -1)) == ams_id:
+                return unit
+        except (TypeError, ValueError):
+            continue
+    return None
+
+
+def blocking_reason_codes(unit: dict | None) -> list[int]:
+    """Known dry_sf_reason codes on this unit, in reported order."""
+    codes = []
+    for code in (unit or {}).get("dry_sf_reason") or []:
+        try:
+            code_int = int(code)
+        except (TypeError, ValueError):
+            continue
+        if code_int in DRY_SF_REASON_MESSAGES:
+            codes.append(code_int)
+    return codes
+
+
+def primary_reason_code(codes: list[int]) -> int | None:
+    """The one code to report when the AMS sets several at once.
+
+    Power and filament-at-the-outlet need the user to go and do something; the
+    rest clear on their own. Naming an actionable one is more use than whichever
+    the firmware happened to list first, and going through here keeps the
+    immediate endpoint's message and the scheduled row's waiting_reason
+    describing the same blocked AMS the same way.
+    """
+    for code in codes:
+        if code in POWER_REASON_CODES:
+            return code
+    if RETRACT_REASON_CODE in codes:
+        return RETRACT_REASON_CODE
+    return codes[0] if codes else None
+
+
+def waiting_reason_for_codes(codes: list[int]) -> str:
+    """Map blocking codes onto the token the frontend translates."""
+    code = primary_reason_code(codes)
+    if code in POWER_REASON_CODES:
+        return WAITING_REASON_POWER
+    if code == RETRACT_REASON_CODE:
+        return WAITING_REASON_RETRACT
+    return WAITING_REASON_BLOCKED
+
+
+def resolve_filament(unit: dict | None, filament: str) -> str:
+    """Fill an empty filament field from the first loaded tray.
+
+    The printer rejects a drying payload with no filament type, so both
+    callers fall back to the loaded spool and then to PLA.
+    """
+    if filament:
+        return filament
+    for tray in (unit or {}).get("tray") or []:
+        tray_type = tray.get("tray_type")
+        if tray_type:
+            return str(tray_type)
+    return "PLA"

+ 104 - 0
backend/app/services/energy_plug.py

@@ -0,0 +1,104 @@
+"""Which of a printer's plugs measures its energy? (#2859)
+
+Per-print energy is the delta of one plug's lifetime counter between print start
+and print end, so both readings have to come from the same plug. The two call
+sites used to assume a printer had exactly one: they selected every plug with
+``SmartPlug.printer_id == printer_id`` and then called ``scalar_one_or_none()``.
+
+Nothing enforces that assumption. The plug API rejects a second *Tasmota* plug
+on a printer and deliberately allows any number of Home Assistant entities --
+"allow multiple per printer (for different automations)" -- which is how a
+filter fan, a dry box or a lights script ends up linked beside the printer's own
+plug. On those installs the query returned two rows, ``scalar_one_or_none()``
+raised, the print-start handler caught it as just another failure and logged a
+warning, and no archive on that printer ever carried an energy figure again. It
+was silent because the print-end handler then reports "no start kWh recorded",
+which reads exactly like "this printer has no plug".
+
+The rule below picks the printer's own plug without asking the user to nominate
+one. ``controls_printer_power`` (#2629) already means "this plug really feeds
+the printer" rather than an accessory that merely follows the print cycle, so it
+ranks above one that does not, and the id breaks ties so the start and end
+readings agree on the answer. The decisive test in practice is the last one: a
+candidate has to actually report a lifetime counter to be chosen, and accessory
+plugs are usually switch-only, so they drop out with nothing configured.
+
+Deliberately *not* enforced: one plug per printer. Bambuddy dropped the UNIQUE
+constraint on ``smart_plugs.printer_id`` on purpose, and the flag defaults to on
+for every existing plug, so clearing it to make it unique would change which
+plugs may mark a printer offline on auto-off (#2629) -- not this module's
+business.
+
+Equally deliberate: nothing is excluded, only ranked. A printer with one linked
+plug used it whatever it was, and must keep doing so, so a disabled row or a
+script still gets its turn once the plausible candidates have declined.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Awaitable, Callable
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.smart_plug import SmartPlug
+
+# Reads a plug's energy dict, or None when the device did not answer. Injected
+# rather than imported so this module stays independent of the plug-type
+# dispatch that lives with the callers.
+EnergyReader = Callable[[SmartPlug, AsyncSession], Awaitable[dict | None]]
+
+
+def _is_script_entity(plug: SmartPlug) -> bool:
+    """A Home Assistant ``script.*`` entity linked to a printer for automation.
+
+    Stored as plugs so they can follow the print cycle (see
+    ``trigger_associated_scripts``), but a script has nothing to meter.
+    """
+    return bool(plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script."))
+
+
+def _rank(plug: SmartPlug) -> tuple:
+    """Sort key: least surprising source of a printer's meter first.
+
+    Ranking rather than filtering, deliberately. A printer with exactly one
+    linked row behaved the same before this module existed whatever that row
+    was -- disabled, a script, an accessory -- and it has to keep behaving that
+    way, so nothing is excluded outright and every rejection is left to the one
+    test that cannot be wrong: does it actually report a counter.
+    """
+    return (
+        _is_script_entity(plug),
+        not plug.enabled,
+        not plug.controls_printer_power,
+        plug.id,
+    )
+
+
+async def energy_plug_candidates(db: AsyncSession, printer_id: int | None) -> list[SmartPlug]:
+    """Plugs on *printer_id* that could supply its energy counter, best first."""
+    if printer_id is None:
+        # `printer_id == None` compiles to `IS NULL`, which would return every
+        # plug linked to no printer at all and bill a print against whichever
+        # one happened to answer. Callers are typed `int`, so this is a guard
+        # against a future one rather than a live path.
+        return []
+    result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
+    return sorted(result.scalars().all(), key=_rank)
+
+
+async def select_energy_reading(
+    candidates: list[SmartPlug],
+    read_energy: EnergyReader,
+    db: AsyncSession,
+) -> tuple[SmartPlug, dict] | None:
+    """First candidate that actually reports a lifetime counter, with its reading.
+
+    Returns the reading alongside the plug so the caller does not poll twice --
+    the value that decided the choice is the value it needs.
+    """
+    for plug in candidates:
+        energy = await read_energy(plug, db)
+        if energy and energy.get("total") is not None:
+            return plug, energy
+    return None

+ 11 - 6
backend/app/services/external_camera.py

@@ -21,6 +21,7 @@ from urllib.parse import urlparse
 import aiohttp
 
 from backend.app.core.logging_filters import redact_url_credentials
+from backend.app.utils.ffmpeg_output import NO_FFMPEG_OUTPUT, summarize_ffmpeg_stderr
 
 logger = logging.getLogger(__name__)
 
@@ -517,7 +518,7 @@ async def _capture_usb_frame(device: str, timeout: int) -> bytes | None:
         stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
 
         if process.returncode != 0:
-            logger.error("ffmpeg USB capture failed: %s", stderr.decode()[:200])
+            logger.error("ffmpeg USB capture failed: %s", summarize_ffmpeg_stderr(stderr) or NO_FFMPEG_OUTPUT)
             return None
 
         if not stdout or len(stdout) < 100:
@@ -701,8 +702,8 @@ async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
         )
 
         if process.returncode != 0:
-            # ffmpeg echoes the RTSP input URL, which carries the camera password.
-            logger.error("ffmpeg RTSP capture failed: %s", redact_url_credentials(stderr.decode())[:200])
+            # The summariser masks the camera password the input URL carries.
+            logger.error("ffmpeg RTSP capture failed: %s", summarize_ffmpeg_stderr(stderr) or NO_FFMPEG_OUTPUT)
             return None
 
         if not stdout or len(stdout) < 100:
@@ -1149,8 +1150,10 @@ async def _stream_rtsp(
         await asyncio.sleep(0.1)
         if process.returncode is not None:
             stderr = await process.stderr.read()
-            # ffmpeg echoes the RTSP input URL, which carries the camera password.
-            logger.error("ffmpeg RTSP stream failed immediately: %s", redact_url_credentials(stderr.decode())[:300])
+            # The summariser masks the camera password the input URL carries.
+            logger.error(
+                "ffmpeg RTSP stream failed immediately: %s", summarize_ffmpeg_stderr(stderr) or NO_FFMPEG_OUTPUT
+            )
             return
 
         buffer = b""
@@ -1262,7 +1265,9 @@ async def _stream_usb(
         await asyncio.sleep(0.5)
         if process.returncode is not None:
             stderr = await process.stderr.read()
-            logger.error("ffmpeg USB stream failed immediately: %s", stderr.decode()[:300])
+            logger.error(
+                "ffmpeg USB stream failed immediately: %s", summarize_ffmpeg_stderr(stderr) or NO_FFMPEG_OUTPUT
+            )
             return
 
         buffer = b""

+ 19 - 0
backend/app/services/failure_analysis.py

@@ -151,6 +151,25 @@ class FailureAnalysisService:
                 select(Printer.id, Printer.name).where(Printer.id.in_(failures_by_printer_id.keys()))
             )
             printer_names = {row[0]: row[1] for row in printers_result.fetchall()}
+            # A printer deleted with its history kept has no row left to read a
+            # name from, and "Printer 3" tells nobody which machine kept failing
+            # (#2873). Each run recorded the name it printed on, so fall back to
+            # the last one that id was known by.
+            missing = [pid for pid in failures_by_printer_id if pid not in printer_names]
+            if missing:
+                last_named_run = (
+                    select(func.max(PrintLogEntry.id).label("entry_id"))
+                    .where(PrintLogEntry.printer_id.in_(missing), PrintLogEntry.printer_name.isnot(None))
+                    .group_by(PrintLogEntry.printer_id)
+                    .subquery()
+                )
+                historic_result = await self.db.execute(
+                    select(PrintLogEntry.printer_id, PrintLogEntry.printer_name).join(
+                        last_named_run, PrintLogEntry.id == last_named_run.c.entry_id
+                    )
+                )
+                for pid, name in historic_result.fetchall():
+                    printer_names[pid] = name
             failures_by_printer = {
                 printer_names.get(pid, f"Printer {pid}"): count for pid, count in failures_by_printer_id.items()
             }

+ 131 - 3
backend/app/services/filament_deficit.py

@@ -38,6 +38,7 @@ from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
+from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
 from backend.app.core.config import settings as app_settings
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.spool_assignment import SpoolAssignment
@@ -186,7 +187,15 @@ def _normalize_color_for_id(raw: str | None) -> str:
     Strips the leading ``#``, uppercases, and drops the alpha channel when
     the hex is 8 chars long (``RRGGBBAA``) so a fully-opaque 8-char hex
     matches a 6-char hex of the same RGB. Empty / None → empty string.
+
+    Anything that is not a string reads as "no colour" rather than raising.
+    In Spoolman mode ``raw`` comes straight off the wire as
+    ``filament.color_hex``, and this runs on the dispatch path — a record
+    holding a number there would otherwise fail a queue start rather than
+    merely fail to pool.
     """
+    if not isinstance(raw, str):
+        raw = None
     s = (raw or "").strip().lstrip("#").upper()
     if len(s) == 8:  # RRGGBBAA → strip alpha
         s = s[:6]
@@ -225,9 +234,14 @@ def _material_identity_spoolman(spool: dict | None) -> str:
     pins the variant. Spools without a resolvable filament id get a
     per-spool unique key so they never pair.
     """
-    if not spool:
+    if not isinstance(spool, dict) or not spool:
         return "unmatched:none"
+    # Both are free-form JSON off the Spoolman API, and this is the dispatch
+    # path: a wrongly-typed member must cost the slot its pool, never the
+    # queue its start.
     filament = spool.get("filament") or {}
+    if not isinstance(filament, dict):
+        filament = {}
     fil_id = filament.get("id")
     if isinstance(fil_id, (int, str)) and str(fil_id).strip():
         # Prefer the per-spool override colour when set (Spoolman lets the user
@@ -301,6 +315,102 @@ async def _get_printer_backup_context(
     return backup_on, ams_extruder_map, is_dual
 
 
+@dataclass(frozen=True)
+class SlotSpoolIdentity:
+    """How the spool bound to a slot should be *named*, as opposed to matched.
+
+    The printer cannot supply this and never will. A tray record carries no
+    brand field at all, and ``tray_sub_brands`` stays empty for anything that
+    isn't a Bambu spool, so a client naming a slot from telemetry alone has
+    only the type and the colour hex to work with — and turns that hex into
+    whichever catalogue colour happens to share it. A Devil Design PLA Basic
+    Orange the operator assigned in Bambuddy reads back as "PLA (Sunflower
+    Yellow)", because Bambu sell a Sunflower Yellow at the same ``FEC600``.
+
+    Only the assignment knows the answer, which is why it is served alongside
+    the pooling key rather than left to the client to resolve: the identity
+    rule differs per inventory mode, and the printer card and the print dialog
+    disagreeing about what is in a slot is the bug this exists to close.
+
+    Purely descriptive — nothing here takes part in matching, which stays on
+    the printer's own telemetry so the dialog and the dispatcher cannot draw
+    different conclusions from the same slot.
+    """
+
+    brand: str | None
+    material: str | None
+    subtype: str | None
+    color_name: str | None
+    rgba: str | None
+
+    def to_dict(self) -> dict:
+        return {
+            "brand": self.brand,
+            "material": self.material,
+            "subtype": self.subtype,
+            "color_name": self.color_name,
+            "rgba": self.rgba,
+        }
+
+
+def _clean(value) -> str | None:
+    """Trim a display field, collapsing blanks to None so the client can skip it."""
+    text = str(value).strip() if value is not None else ""
+    return text or None
+
+
+def _identity_from_internal(spool) -> SlotSpoolIdentity:
+    """Display identity from an internal-inventory ``Spool`` row."""
+    return SlotSpoolIdentity(
+        brand=_clean(spool.brand),
+        material=_clean(spool.material),
+        subtype=_clean(spool.subtype),
+        color_name=_clean(spool.color_name),
+        rgba=_clean(spool.rgba),
+    )
+
+
+def _identity_from_spoolman(spool_dict: dict) -> SlotSpoolIdentity | None:
+    """Display identity from a raw Spoolman spool dict, or None if unreadable.
+
+    Goes through ``_map_spoolman_spool`` rather than reading the dict directly:
+    brand lives on the nested vendor, subtype is the filament name with its
+    material prefix stripped, and ``color_name`` has a three-step read order
+    Spoolman itself has no field for. Re-deriving any of that here is how the
+    two modes would drift apart.
+    """
+    # Broad on purpose. This is a name for a dropdown, and the caller is on the
+    # dispatch path -- ``compute_deficit_for_queue_item`` runs it before every
+    # queue start. ``_map_spoolman_spool`` walks a dozen nested fields off the
+    # wire (``filament.vendor.name``, ``extra.tag``, ``filament.color_hex``) and
+    # any of them arriving as the wrong type raises AttributeError rather than
+    # ValueError, so a narrow catch here would turn one malformed Spoolman
+    # record into a failed dispatch. Losing the name costs a fallback to
+    # telemetry, which is what every slot did before this existed.
+    try:
+        mapped = _map_spoolman_spool(spool_dict)
+    except Exception as exc:  # noqa: BLE001 - display-only, must never block a dispatch
+        logger.debug(
+            "Spoolman spool %r has no usable display identity: %s",
+            spool_dict.get("id") if isinstance(spool_dict, dict) else spool_dict,
+            exc,
+        )
+        return None
+    # Spoolman has no colour-name field, so `_map_spoolman_spool` synthesises
+    # one from the subtype when nothing is stored -- which reads fine in an
+    # inventory list ("PLA Basic") and badly as a colour ("Devil Design PLA
+    # Basic (Basic)"). Drop it and let the client's catalogue lookup name the
+    # hex, which is what an unnamed slot got before this existed.
+    color_name = None if mapped.get("color_name_is_synthesized") else _clean(mapped.get("color_name"))
+    return SlotSpoolIdentity(
+        brand=_clean(mapped.get("brand")),
+        material=_clean(mapped.get("material")),
+        subtype=_clean(mapped.get("subtype")),
+        color_name=color_name,
+        rgba=_clean(mapped.get("rgba")),
+    )
+
+
 @dataclass(frozen=True)
 class SlotMaterial:
     """One inventory-bound AMS slot: what's in it, how much is left, which side."""
@@ -314,6 +424,9 @@ class SlotMaterial:
     material_key: str
     remaining_grams: float
     extruder: int
+    # Display-only; see SlotSpoolIdentity. None when the binding resolves to a
+    # spool we cannot describe, which callers render from telemetry as before.
+    spool: SlotSpoolIdentity | None = None
 
     def to_dict(self) -> dict:
         return {
@@ -323,6 +436,7 @@ class SlotMaterial:
             "material_key": self.material_key,
             "remaining_g": self.remaining_grams,
             "extruder": self.extruder,
+            "spool": self.spool.to_dict() if self.spool else None,
         }
 
 
@@ -344,7 +458,13 @@ async def build_slot_materials(db: AsyncSession, printer_id: int) -> list[SlotMa
     _, 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:
+    def _append(
+        ams_id: int,
+        tray_id: int,
+        material_key: str,
+        remaining: float,
+        spool: SlotSpoolIdentity | None = None,
+    ) -> None:
         materials.append(
             SlotMaterial(
                 ams_id=ams_id,
@@ -353,6 +473,7 @@ async def build_slot_materials(db: AsyncSession, printer_id: int) -> list[SlotMa
                 material_key=material_key,
                 remaining_grams=remaining,
                 extruder=_extruder_side_for_ams(ams_id, ams_extruder_map, is_dual),
+                spool=spool,
             )
         )
 
@@ -391,7 +512,13 @@ async def build_slot_materials(db: AsyncSession, printer_id: int) -> list[SlotMa
                     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)
+            _append(
+                sa.ams_id,
+                sa.tray_id,
+                _material_identity_spoolman(spool_dict),
+                remaining,
+                _identity_from_spoolman(spool_dict),
+            )
         return materials
 
     internal_all = await db.execute(
@@ -412,6 +539,7 @@ async def build_slot_materials(db: AsyncSession, printer_id: int) -> list[SlotMa
             assignment.tray_id,
             _material_identity_internal(spool),
             max(0.0, label_weight - weight_used),
+            _identity_from_internal(spool),
         )
     return materials
 

+ 6 - 0
backend/app/services/firmware_update.py

@@ -344,6 +344,11 @@ class FirmwareUpdateService:
                     progress_callback=on_upload_progress,
                     socket_timeout=ftp_timeout,
                     printer_model=model,
+                    # Someone pressed "update firmware" and is watching a
+                    # progress bar. Bounded and user-initiated, like a print
+                    # dispatch, so it does not spend its retries on a cool-off
+                    # meant for the background sweeps (#2898).
+                    respect_handshake_cooloff=False,
                     max_retries=ftp_retry_count,
                     retry_delay=ftp_retry_delay,
                     operation_name=f"Upload firmware to printer {printer_id}",
@@ -357,6 +362,7 @@ class FirmwareUpdateService:
                     progress_callback=on_upload_progress,
                     socket_timeout=ftp_timeout,
                     printer_model=model,
+                    respect_handshake_cooloff=False,
                 )
 
             if not success:

+ 61 - 28
backend/app/services/ftp_profiles.py

@@ -45,12 +45,27 @@ class FTPProfile:
     # the printer). Capping to TLS 1.2 makes session resumption
     # synchronous and the upload completes normally.
     #
-    # Note this cap only bites on models that *offer* 1.3 in the first
-    # place. Probed directly on :990, an X1C and an H2D both refuse
-    # TLS 1.0, 1.1 and 1.3 with a handshake_failure alert and complete
-    # only on 1.2 — so for those models the cap is a no-op and the
-    # negotiated version was never 1.3. The P2S evidently does offer
-    # 1.3, which is why it alone surfaced the session-reuse bug.
+    # This cap only bites on models that *offer* 1.3 in the first place,
+    # and on the evidence so far none of them do. Probed directly on
+    # :990, an X1C and an H2D refuse TLS 1.0, 1.1 and 1.3 and complete
+    # only on 1.2; @grolmus then probed a 9-printer farm (#2780,
+    # 2026-08-21) and got the same result on six P2S units, two X1C and
+    # an H2D — tls1_3 refused, tls1_2 ok, every one. This comment used
+    # to claim "the P2S evidently does offer 1.3"; six say otherwise.
+    #
+    # A cap is also not needed to reach a 1.2-only peer. Measured
+    # against a local TLS-1.2-only server with the same context this
+    # module builds: an uncapped client negotiates 1.2 and connects.
+    # A client forced to 1.3 gets TLSV1_ALERT_PROTOCOL_VERSION — never
+    # WRONG_VERSION_NUMBER, which comes from bytes that are not a TLS
+    # record at all. See
+    # ``tests/unit/services/test_cleartext_probe_2780.py``, which pins
+    # both measurements so this comment stays falsifiable.
+    #
+    # So the entries below are kept as tuning slots and as a record of
+    # what each reporter saw, not because the mechanism is understood.
+    # Two of the three explain a symptom this cap cannot affect; see
+    # their own comments.
     # (P1S untested; no claim made either way.)
     #
     # **Defaults to False** — only applied to printer models where a
@@ -72,35 +87,53 @@ DEFAULT_PROFILE = FTPProfile()
 # AFTER alias normalisation, so internal SSDP codes ("N7") resolve via
 # ``_MODEL_ALIASES`` below.
 _PROFILES: dict[str, FTPProfile] = {
-    # P2S firmware 01.02.00.00 trips the vsFTPd + TLS 1.3 session-reuse
-    # bug on the FTPS data channel (#1401, reporter @iitazz). Cap to
-    # TLS 1.2 so session resumption is synchronous and the upload
-    # completes.
+    # P2S firmware 01.02.00.00 (#1401, reporter @iitazz). Symptom is a
+    # 426 truncation part-way through a transfer, on the data channel —
+    # a different failure from the handshake ones below, and the only
+    # one here whose mechanism a TLS-1.3 session-ticket problem could
+    # actually explain. The reporter confirmed the fix.
+    #
+    # Unresolved: @grolmus's six P2S units refuse TLS 1.3 outright
+    # (#2780), so on their firmware the negotiated version was already
+    # 1.2 and this cap changes nothing. Either the firmware moved
+    # between the two reports, or #1401 was fixed by something else in
+    # the same change. Kept because a reporter confirmed it and no one
+    # has hardware to re-test it on.
     "P2S": FTPProfile(
         cap_tls_v1_2=True,
     ),
     # X2D firmware 01.01.00.00 fails the implicit-FTPS handshake on
-    # port 990 with ``[SSL: WRONG_VERSION_NUMBER]`` against Python
-    # 3.13's default TLS-1.3 ClientHello (#1638, reporter @vasmarfas).
-    # Without the 3MF download the print falls through to the no-3MF
-    # fallback archive path and the card lands almost empty (no
-    # filament total, no layers, no MakerWorld link). Cap to TLS 1.2
-    # by analogy with P2S; if the symptom turns out to be a different
-    # FTPS variant on the X2D (explicit AUTH TLS, different port) the
-    # entry stays useful as a per-model tuning slot for the follow-up.
+    # port 990 with ``[SSL: WRONG_VERSION_NUMBER]`` (#1638, reporter
+    # @vasmarfas). Without the 3MF download the print falls through to
+    # the no-3MF fallback archive path and the card lands almost empty
+    # (no filament total, no layers, no MakerWorld link).
+    #
+    # RE-TEST WANTED. This was capped on the reading that the error came
+    # from "Python 3.13's default TLS-1.3 ClientHello". That reading is
+    # now measured wrong: WRONG_VERSION_NUMBER is what a *non-TLS*
+    # answer produces, a version mismatch reports itself differently,
+    # and an uncapped client reaches a 1.2-only peer unaided (#2780).
+    # So this cap cannot be what changed the outcome, and the X2D is
+    # most likely answering :990 with something that is not TLS — the
+    # cleartext probe in ``bambu_ftp`` will now say what. Left in place
+    # rather than removed: nobody here has an X2D, and the entry costs
+    # nothing on a printer that does not offer 1.3 anyway.
     "X2D": FTPProfile(
         cap_tls_v1_2=True,
     ),
-    # H2C firmware 01.02.00.00 (#2582, reporter @gyrene2083) — same H2
-    # generation and same firmware line as P2S, and with no profile it
-    # ran on the Python-default TLS 1.3. Reported symptom is exactly the
-    # one the X2D comment describes: the sliced 3MF intermittently fails
-    # to come off the printer over FTPS, so the print drops to the no-3MF
-    # fallback archive with no slice data — which is why the Print Log
-    # shows no filament and nothing is deducted. Cap to TLS 1.2 by analogy
-    # with P2S (intermittent "sometimes works" points at the session-reuse
-    # variant, not X2D's deterministic handshake failure); if a debug
-    # capture shows a different FTPS variant the entry stays the tuning slot.
+    # H2C firmware 01.02.00.00 (#2582, reporter @gyrene2083). The sliced
+    # 3MF intermittently fails to come off the printer over FTPS, so the
+    # print drops to the no-3MF fallback archive with no slice data —
+    # which is why the Print Log shows no filament and nothing is
+    # deducted.
+    #
+    # RE-TEST WANTED, same reasoning as the X2D above. Capped "by
+    # analogy with P2S" on the belief that the profile-less path "ran on
+    # the Python-default TLS 1.3"; measurement says a 1.2-only peer
+    # negotiates 1.2 without a cap, so there was no 1.3 to fall back
+    # from (#2780). "Intermittent" now points somewhere better: it is
+    # the signature of the transient non-TLS refusal @grolmus sees on
+    # his P2S units, which is the same H2 firmware line.
     "H2C": FTPProfile(
         cap_tls_v1_2=True,
     ),

+ 47 - 7
backend/app/services/ha_sensor_manager.py

@@ -18,12 +18,13 @@ door contact that stops responding must not strand the queue.
 import asyncio
 import logging
 from dataclasses import dataclass
+from typing import Protocol
 
 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.models.printer_ha_sensor import LAST_STATE_MAX_LENGTH, PrinterHASensor
 from backend.app.services.homeassistant import as_float, homeassistant_service
 from backend.app.utils.local_time import utcnow_naive
 
@@ -44,6 +45,25 @@ class SensorReading:
     reachable: bool
 
 
+def persistable_state(state: str | None, max_length: int) -> str | None:
+    """Fit a raw HA state into a last_state column.
+
+    A numeric entity can start reporting free text (an enum, an error string)
+    longer than the column. PostgreSQL rejects the oversized row, and since a
+    poll pass commits every sensor at once, one such entity would sink every
+    other sensor's update on every tick -- and for printer sensors that also
+    freezes the print interlock's view of the world.
+
+    The cached SensorReading keeps the full state; only what is persisted is
+    cut, and the comparison against the stored value is done on the cut form so
+    an unchanged-but-long state does not read as a change on every poll.
+
+    Shared with the storage-location poller, which has the same column on its
+    own table -- each caller passes its own model's width.
+    """
+    return state[:max_length] if state else state
+
+
 class HASensorManager:
     def __init__(self):
         self._task: asyncio.Task | None = None
@@ -162,8 +182,9 @@ class HASensorManager:
             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
+        persisted = persistable_state(reading.state, LAST_STATE_MAX_LENGTH)
+        if reading.reachable and sensor.last_state != persisted:
+            sensor.last_state = persisted
             sensor.last_changed = sensor.last_checked
         await db.commit()
         await db.refresh(sensor)
@@ -196,8 +217,9 @@ class HASensorManager:
 
             sensor.last_checked = now
             if reading.reachable:
-                if sensor.last_state != reading.state:
-                    sensor.last_state = reading.state
+                persisted = persistable_state(reading.state, LAST_STATE_MAX_LENGTH)
+                if sensor.last_state != persisted:
+                    sensor.last_state = persisted
                     sensor.last_changed = now
 
             # Notify on the edge into alerting only. `was_alerting is None` is
@@ -228,7 +250,25 @@ class HASensorManager:
                 logger.warning("Failed to send HA sensor alert for '%s': %s", sensor.name, e)
 
 
-def evaluate(sensor: PrinterHASensor, payload: dict | None) -> SensorReading:
+class _AlertableSensor(Protocol):
+    """Structural type for evaluate()/describe_state().
+
+    PrinterHASensor and LocationHASensor are unrelated SQLAlchemy models —
+    one has no base class in common with the other beyond ``Base`` — but both
+    carry these five fields with the same meaning, and location_ha_sensor_
+    manager.py imports these two functions to reuse the exact same alert
+    logic rather than reimplementing it. A concrete PrinterHASensor
+    annotation here would be a lie for half of the actual callers.
+    """
+
+    kind: str
+    unit: str | None
+    alert_state: str | None
+    alert_above: float | None
+    alert_below: float | None
+
+
+def evaluate(sensor: _AlertableSensor, 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
@@ -261,7 +301,7 @@ def evaluate(sensor: PrinterHASensor, payload: dict | None) -> SensorReading:
     return SensorReading(state=normalized, value=None, alerting=alerting, reachable=True)
 
 
-def describe_state(sensor: PrinterHASensor, reading: SensorReading) -> str:
+def describe_state(sensor: _AlertableSensor, 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}"

+ 45 - 0
backend/app/services/hms_errors.py

@@ -873,3 +873,48 @@ def get_error_description(error_code: str) -> str | None:
         Human-readable description or None if not found
     """
     return HMS_ERROR_DESCRIPTIONS.get(error_code.upper())
+
+
+def describe_fault(full_code: str | None) -> str | None:
+    """Resolve a fault's description from the canonical `full_code`.
+
+    `full_code` is the identifier the firmware itself matches on: 8 hex chars
+    for a 32-bit `print_error`, 16 for a 64-bit `hms[]` entry. This is the one
+    place that maps either shape onto this table, so every surface that reports
+    a fault says the same thing about it.
+
+    An 8-char code is this table's `MMMM_EEEE` key with the separator removed --
+    the parser derives `full_code` and that key from the same 32-bit value -- so
+    it resolves exactly.
+
+    A 16-char code is tried whole first, then collapsed to `G1_G4` (the first
+    and last of its four hex groups). That collapse is lossy and not injective:
+    it discards the Part No. and Alert level groups, and #2728 measured 65
+    documented faults falling onto `0300_0001` alone, so a hit can in principle
+    attribute a neighbouring fault's sentence to this one. It is kept because it
+    is what this codebase has always done -- the notification path, the queue's
+    failure-reason helper and the frontend modal all resolve `hms[]` faults this
+    way, and it does resolve real ones (a `0500_4038` nozzle mismatch arrives in
+    that shape). Refusing to collapse would not be a stricter reading of the
+    same data; it would silently stop describing faults that are described
+    today, and leave this field null while the UI shows text for the same fault.
+    Narrowing it is #2728's subject, and belongs there where the key spaces can
+    be changed together.
+
+    Returns None for an empty, malformed, or unknown code.
+    """
+    if not full_code:
+        return None
+    code = full_code.strip().upper()
+    if len(code) == 8:
+        return HMS_ERROR_DESCRIPTIONS.get(f"{code[:4]}_{code[4:]}")
+    if len(code) == 16:
+        # `is not None` rather than truthiness: an entry whose text is empty is
+        # still an entry, and falling through on it would resolve the fault to a
+        # neighbour's sentence. No blank values ship today; the frontend lookup
+        # draws the same distinction and a regenerated catalogue could.
+        exact = HMS_ERROR_DESCRIPTIONS.get(code)
+        if exact is not None:
+            return exact
+        return HMS_ERROR_DESCRIPTIONS.get(f"{code[:4]}_{code[12:]}")
+    return None

+ 42 - 0
backend/app/services/inventory_mode.py

@@ -0,0 +1,42 @@
+"""Which table holds a printer's slot assignments.
+
+Bambuddy keeps AMS slot assignments in two places: ``spool_assignment`` for the
+built-in inventory and ``spoolman_slot_assignments`` for Spoolman. Exactly one
+of them describes reality at any moment, and which one is a user setting.
+
+Until #2812 the two were kept from overlapping by emptying the inactive table
+whenever the mode toggled, which made merely looking at the other mode destroy
+the configuration you had. Nothing is deleted now, so both tables can hold rows
+at once and every reader has to say which one it means.
+
+This is deliberately a module of its own rather than a helper on the settings
+routes: the readers are services, and importing an API route module from a
+service to answer a one-key question invites an import cycle. Several call
+sites already carry their own private copy of this predicate for that reason
+(``filament_deficit``, ``print_scheduler``, ``inventory``); those are unchanged
+and correct, and are only worth folding in here if they are touched anyway.
+"""
+
+import logging
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+logger = logging.getLogger(__name__)
+
+
+async def spoolman_owns_assignments(db: AsyncSession) -> bool:
+    """True when ``spoolman_slot_assignments`` is the table that counts.
+
+    Fails closed to the built-in inventory: a setting that cannot be read is
+    not evidence that the user switched modes, and treating an unreadable
+    setting as "Spoolman" would make a built-in install look as though every
+    tray were unassigned.
+    """
+    try:
+        from backend.app.api.routes.settings import get_setting
+
+        value = await get_setting(db, "spoolman_enabled")
+        return bool(value) and value.lower() == "true"
+    except Exception as exc:  # noqa: BLE001 — a mode probe must not raise into its callers
+        logger.debug("Could not read spoolman_enabled, assuming built-in inventory: %s", exc)
+        return False

+ 61 - 23
backend/app/services/label_renderer.py

@@ -154,24 +154,28 @@ def _qr_png_bytes(payload: str, *, box_size: int = 4, border: int = 2) -> bytes:
 def _draw_swatch(c: rl_canvas.Canvas, x: float, y: float, w: float, h: float, data: LabelData) -> None:
     """Draw the colour swatch. Multi-colour spools use vertical stripes
     (matching the FilamentSwatch convention in the frontend)."""
-    primary = _color_from_hex(data.rgba)
-    extras = [_color_from_hex(h) for h in (data.extra_colors or []) if h]
-    colors = [primary, *extras]
+    c.saveState()
+    try:
+        primary = _color_from_hex(data.rgba)
+        extras = [_color_from_hex(h) for h in (data.extra_colors or []) if h]
+        colors = [primary, *extras]
 
-    if not colors:
-        c.setFillColor(HexColor(0x808080))
-        c.rect(x, y, w, h, stroke=0, fill=1)
-        return
+        if not colors:
+            c.setFillColor(HexColor(0x808080))
+            c.rect(x, y, w, h, stroke=0, fill=1)
+            return
 
-    stripe_w = w / len(colors)
-    for i, col in enumerate(colors):
-        c.setFillColor(col)
-        c.rect(x + i * stripe_w, y, stripe_w, h, stroke=0, fill=1)
+        stripe_w = w / len(colors)
+        for i, col in enumerate(colors):
+            c.setFillColor(col)
+            c.rect(x + i * stripe_w, y, stripe_w, h, stroke=0, fill=1)
 
-    # Thin black border so light-colour swatches stay visible on white labels.
-    c.setStrokeColor(black)
-    c.setLineWidth(0.3)
-    c.rect(x, y, w, h, stroke=1, fill=0)
+        # Thin black border so light-colour swatches stay visible on white labels.
+        c.setStrokeColor(black)
+        c.setLineWidth(0.3)
+        c.rect(x, y, w, h, stroke=1, fill=0)
+    finally:
+        c.restoreState()
 
 
 def _roomy_qr_size(inner_w: float, inner_h: float) -> float:
@@ -442,7 +446,20 @@ def _render_single_label_pdf(template: TemplateName, data_list: list[LabelData],
     return buf.getvalue()
 
 
-def _render_sheet_pdf(template: TemplateName, data_list: list[LabelData], monochrome: bool = False) -> bytes:
+def get_sheet_capacity(template: TemplateName) -> int | None:
+    """Return the number of slots on a sheet template, or ``None`` for roll labels."""
+    layout = _SHEET_TEMPLATES.get(template)
+    if layout is None:
+        return None
+    return layout[3] * layout[4]
+
+
+def _render_sheet_pdf(
+    template: TemplateName,
+    data_list: list[LabelData],
+    monochrome: bool,
+    starting_position: int,
+) -> bytes:
     page_size, w_mm, h_mm, cols, rows, top_mm, left_mm, col_gap_mm, row_gap_mm = _SHEET_TEMPLATES[template]
     page_w, page_h = page_size
 
@@ -458,21 +475,37 @@ def _render_sheet_pdf(template: TemplateName, data_list: list[LabelData], monoch
     c.setTitle(f"Bambuddy spool labels ({template})")
 
     per_page = cols * rows
-    for page_start in range(0, len(data_list), per_page):
-        chunk = data_list[page_start : page_start + per_page]
+    if starting_position < 1 or starting_position > per_page:
+        raise ValueError(f"Starting position must be between 1 and {per_page} for {template}")
+
+    data_index = 0
+    page_number = 0
+    while data_index < len(data_list):
+        slot_offset = starting_position - 1 if page_number == 0 else 0
+        page_capacity = per_page - slot_offset
+        chunk = data_list[data_index : data_index + page_capacity]
         for idx, data in enumerate(chunk):
-            row = idx // cols
-            col = idx % cols
+            slot_index = slot_offset + idx
+            row = slot_index // cols
+            col = slot_index % cols
             x = left_margin + col * (label_w + col_gap)
             y = page_h - top_margin - (row + 1) * label_h - row * row_gap
             _draw_label(c, x, y, label_w, label_h, data, monochrome)
         c.showPage()
+        data_index += len(chunk)
+        page_number += 1
 
     c.save()
     return buf.getvalue()
 
 
-def render_labels(template: TemplateName, data_list: list[LabelData], *, monochrome: bool = False) -> bytes:
+def render_labels(
+    template: TemplateName,
+    data_list: list[LabelData],
+    *,
+    monochrome: bool = False,
+    starting_position: int = 1,
+) -> bytes:
     """Render ``data_list`` to a PDF using the named template. Returns bytes.
 
     Empty ``data_list`` still produces a valid (empty) PDF — callers should
@@ -481,14 +514,19 @@ def render_labels(template: TemplateName, data_list: list[LabelData], *, monochr
     ``monochrome`` drops the colour swatch (which prints as a useless grey block
     on black-and-white thermal printers) and reclaims the space for text; the
     hex-code line still carries the colour. See #1870.
+
+    ``starting_position`` is one-based and applies only to the first page of a
+    sheet template. Later pages always begin at the first slot.
     """
     if template in _SINGLE_LABEL_SIZES_MM:
+        if starting_position != 1:
+            raise ValueError("Starting position is only supported for sheet label templates")
         return _render_single_label_pdf(template, data_list, monochrome)
     if template in _SHEET_TEMPLATES:
-        return _render_sheet_pdf(template, data_list, monochrome)
+        return _render_sheet_pdf(template, data_list, monochrome, starting_position)
     raise ValueError(f"Unknown label template: {template!r}")
 
 
-__all__ = ["LabelData", "TemplateName", "render_labels"]
+__all__ = ["LabelData", "TemplateName", "get_sheet_capacity", "render_labels"]
 # white re-exported for completeness; future templates may need a paper-tone variant.
 _ = white

+ 2 - 1
backend/app/services/layer_timelapse.py

@@ -14,6 +14,7 @@ from pathlib import Path
 from backend.app.core.config import settings
 from backend.app.services.camera import apply_camera_rotation
 from backend.app.services.external_camera import capture_frame
+from backend.app.utils.ffmpeg_output import NO_FFMPEG_OUTPUT, summarize_ffmpeg_stderr
 
 logger = logging.getLogger(__name__)
 
@@ -189,7 +190,7 @@ class TimelapseSession:
             stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=300)
 
             if process.returncode != 0:
-                logger.error("ffmpeg timelapse stitch failed: %s", stderr.decode()[:500])
+                logger.error("ffmpeg timelapse stitch failed: %s", summarize_ffmpeg_stderr(stderr) or NO_FFMPEG_OUTPUT)
                 return False
 
             logger.info("Created timelapse video: %s (%s frames)", output_path, self.frame_count)

+ 216 - 0
backend/app/services/location_ha_sensor_manager.py

@@ -0,0 +1,216 @@
+import asyncio
+import logging
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.location import Location
+from backend.app.models.location_ha_sensor import LAST_STATE_MAX_LENGTH, LocationHASensor
+from backend.app.models.settings import Settings
+from backend.app.services.ha_sensor_manager import SensorReading, describe_state, evaluate, persistable_state
+from backend.app.services.homeassistant import homeassistant_service
+from backend.app.utils.local_time import utcnow_naive
+
+logger = logging.getLogger(__name__)
+
+POLL_INTERVAL = 120
+MIN_POLL_INTERVAL = 60
+
+
+class LocationHASensorManager:
+    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 sensor. Absent means "never had a reachable reading".
+        self._last_alerting: dict[int, bool] = {}
+
+    def start(self):
+        if self._task is None:
+            self._task = asyncio.create_task(self._poll_loop())
+            logger.info("Home Assistant location-sensor poller started")
+
+    def stop(self):
+        if self._task:
+            self._task.cancel()
+            self._task = None
+            logger.info("Home Assistant location-sensor poller stopped")
+
+    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 _poll_loop(self):
+        # Poll first, sleep after — the interval is configurable and can be
+        # minutes long, and a restart should not leave every location's
+        # reading blank on the card for a full interval before the first one
+        # lands.
+        while True:
+            try:
+                await self.poll_once()
+            except asyncio.CancelledError:
+                break
+            except Exception as e:
+                logger.warning("Home Assistant location-sensor poll failed: %s", e)
+            try:
+                await asyncio.sleep(await self._get_poll_interval())
+            except asyncio.CancelledError:
+                break
+            except Exception as e:
+                # _get_poll_interval() reads Settings, so this leg does I/O
+                # and a transient database failure (pool exhaustion, a
+                # restarting server) can raise here. Letting it escape ends
+                # the task for good: stop() is what clears self._task, so a
+                # loop that died on its own leaves it set and start() will
+                # not revive it — location sensors would stay frozen until
+                # the process restarts. The poll_once() call above already
+                # survives the same error one line earlier.
+                logger.warning("Home Assistant location-sensor poll interval lookup failed: %s", e)
+                await asyncio.sleep(POLL_INTERVAL)
+
+    async def _get_poll_interval(self) -> int:
+        """User-configurable poll cadence, clamped to a sane floor.
+
+        Falls back to the default on a missing row or a corrupted value
+        rather than raising — a bad setting must not take the poller down.
+        """
+        from backend.app.core.database import async_session
+
+        async with async_session() as db:
+            result = await db.execute(select(Settings).where(Settings.key == "location_sensor_poll_interval"))
+            row = result.scalar_one_or_none()
+        if row is None:
+            return POLL_INTERVAL
+        try:
+            return max(MIN_POLL_INTERVAL, int(row.value))
+        except (TypeError, ValueError):
+            return POLL_INTERVAL
+
+    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(LocationHASensor))
+            sensors = list(result.scalars().all())
+
+            # Drop readings for rows that no longer exist. The delete route
+            # calls forget(), but a location 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):
+                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: LocationHASensor):
+        """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()
+        persisted = persistable_state(reading.state, LAST_STATE_MAX_LENGTH)
+        if reading.reachable and sensor.last_state != persisted:
+            sensor.last_state = persisted
+            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[LocationHASensor], 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[LocationHASensor, 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:
+                persisted = persistable_state(reading.state, LAST_STATE_MAX_LENGTH)
+                if sensor.last_state != persisted:
+                    sensor.last_state = persisted
+                    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 drybox that was
+            # already too humid then has not just become too humid, 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.location: touching the lazy relationship from
+            # an async session raises MissingGreenlet.
+            location = await db.get(Location, sensor.location_id)
+            try:
+                await notification_service.on_location_ha_sensor_alert(
+                    location_name=location.name if location 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)
+
+
+location_ha_sensor_manager = LocationHASensorManager()

+ 22 - 0
backend/app/services/location_service.py

@@ -3,6 +3,7 @@
 from __future__ import annotations
 
 import logging
+import re
 import time
 from dataclasses import dataclass
 
@@ -18,6 +19,24 @@ logger = logging.getLogger(__name__)
 
 DUPLICATE_LOCATION_NAME = "A location with this name already exists"
 
+# AMS residency markers, not storage locations. Bambuddy used to write the slot
+# a spool was loaded into -- "<printer> - AMS A1", the shape
+# `SpoolmanClient.convert_ams_slot_to_location` still produces -- straight into
+# Spoolman's `location` field. That writer went away when Storage Location
+# became a place the user chooses (#1114), but the strings survive on people's
+# Spoolman spools, and importing them offers a printer slot as somewhere to put
+# a spool away. A slot is where a spool is loaded, not where it is stored, and
+# Bambuddy tracks that separately through slot assignments.
+_AMS_SLOT_LOCATION_RE = re.compile(
+    r"^(?:.+\s-\s)?(?:AMS[- ]HT [A-Z]\d+|AMS [A-Z]\d+|External Spool)$",
+    re.IGNORECASE,
+)
+
+
+def is_ams_slot_location(name: str) -> bool:
+    """True when a location string names a printer slot rather than a storage place."""
+    return bool(_AMS_SLOT_LOCATION_RE.match(name.strip()))
+
 
 def normalize_location_name(name: str) -> str:
     trimmed = name.strip()
@@ -281,6 +300,9 @@ async def sync_locations_from_spoolman(db: AsyncSession, client) -> bool:
         name = (raw or "").strip()
         if not name:
             continue
+        if is_ams_slot_location(name):
+            logger.debug("Skipping AMS slot marker %r from the Spoolman location import", name)
+            continue
         key = location_name_key(name)
         if key not in by_key:
             by_key[key] = name

+ 38 - 0
backend/app/services/notification_service.py

@@ -1869,6 +1869,44 @@ class NotificationService:
             variables=variables,
         )
 
+    async def on_location_ha_sensor_alert(
+        self,
+        location_name: str,
+        sensor_name: str,
+        state: str,
+        db: AsyncSession,
+    ):
+        """A Home Assistant sensor bound to a storage location entered its alert state (#2824).
+
+        Sent immediately rather than folded into a digest, for the same reason
+        as on_ha_sensor_alert above: this is the "drybox went stale" case, only
+        worth acting on while the humidity/temperature is still climbing.
+        """
+        # Own column, not on_ha_sensor_alert (#2824): that one can be scoped to
+        # a single printer via provider.printer_id, and a location alert has no
+        # printer to scope by, so sharing it would leak drybox alerts to a
+        # provider narrowed to one printer's sensors.
+        providers = await self._get_providers_for_event(db, "on_location_ha_sensor_alert", None)
+        if not providers:
+            return
+
+        variables = {
+            "location": location_name,
+            "sensor": sensor_name,
+            "state": state,
+        }
+
+        title, message = await self._build_message_from_template(db, "location_ha_sensor_alert", variables)
+        await self._send_to_providers(
+            providers,
+            title,
+            message,
+            db,
+            "location_ha_sensor_alert",
+            force_immediate=True,
+            variables=variables,
+        )
+
     async def on_first_layer_complete(
         self,
         printer_id: int,

+ 63 - 16
backend/app/services/obico_detection.py

@@ -96,8 +96,14 @@ class ObicoDetectionService:
         self._states: dict[int, PrintState] = {}
         # printer_id -> task_name active when state was created (used to detect new prints)
         self._state_keys: dict[int, str] = {}
-        # printer_id -> last classification ("safe"/"warning"/"failure")
+        # printer_id -> last classification ("safe"/"warning"/"failure").
+        # Only written after an inference actually came back, so a missing entry
+        # means "we have no verdict", which is not the same as "safe" (#2952).
         self._last_class: dict[int, str] = {}
+        # printer_id -> why the most recent poll produced no verdict, or absent
+        # when the last poll succeeded. Per-printer rather than global so a card
+        # can say what went wrong for *that* printer.
+        self._errors: dict[int, str] = {}
         # printer_id -> whether an action has already been fired for the current print
         self._action_fired: dict[int, bool] = {}
         # Global detection event log (most-recent-first)
@@ -191,6 +197,8 @@ class ObicoDetectionService:
                 self._states.pop(printer_id, None)
                 self._state_keys.pop(printer_id, None)
                 self._action_fired.pop(printer_id, None)
+                self._last_class.pop(printer_id, None)
+                self._errors.pop(printer_id, None)
                 continue
 
             await self._check_printer(printer_id, status, settings)
@@ -261,6 +269,17 @@ class ObicoDetectionService:
             timeout=SNAPSHOT_CAPTURE_TIMEOUT,
         )
 
+    def _no_verdict(self, printer_id: int, reason: str) -> None:
+        """Record that this poll produced no verdict for ``printer_id``.
+
+        Kept separate from the classification so the status surface can say
+        "not checking" instead of inheriting the previous verdict — or, worse,
+        the default "safe" a printer used to get before its first inference.
+        """
+        self._errors[printer_id] = reason
+        self._last_error = reason
+        logger.warning(reason)
+
     async def _check_printer(self, printer_id: int, status, settings: dict):
         task_name = getattr(status, "task_name", None) or getattr(status, "subtask_name", "") or ""
         key = f"{task_name}"
@@ -275,17 +294,16 @@ class ObicoDetectionService:
         # keyframe wait.
         frame = await self._capture_frame(printer_id)
         if not frame:
-            self._last_error = f"Failed to capture snapshot for printer {printer_id}"
-            logger.warning(self._last_error)
+            self._no_verdict(printer_id, f"Failed to capture snapshot for printer {printer_id}")
             return
 
         external_url = settings.get("external_url") or ""
         if not external_url:
-            self._last_error = (
+            self._no_verdict(
+                printer_id,
                 "external_url setting is empty — Obico's ML API needs a reachable URL to fetch the snapshot from. "
-                "Set Settings → General → External URL."
+                "Set Settings → General → External URL.",
             )
-            logger.warning(self._last_error)
             return
 
         nonce = await stash_frame(frame)
@@ -304,19 +322,23 @@ class ObicoDetectionService:
                     # Say so plainly: the health endpoint is ungated, so "Test
                     # Connection" passes against exactly this configuration and
                     # a raw 401 gives the user nothing to act on (#2733).
-                    self._last_error = (
+                    #
+                    # Obico's auth decorator runs before the handler, so a call
+                    # rejected here leaves no trace in the ML API's own log —
+                    # which is how #2952 came to be reported as "the loop never
+                    # calls the ML API" while it was calling it every 10s.
+                    self._no_verdict(
+                        printer_id,
                         "Obico ML API rejected the token (401). Set Settings → Failure Detection → "
                         "ML API Token to the ML_API_TOKEN the server runs with, or clear ML_API_TOKEN "
-                        "on the server."
+                        "on the server.",
                     )
-                    logger.warning("%s (printer %s)", self._last_error, printer_id)
                     return
                 resp.raise_for_status()
                 payload = resp.json()
         except Exception as e:
             detail = str(e) or type(e).__name__
-            self._last_error = f"ML API call failed for printer {printer_id}: {detail}"
-            logger.warning(self._last_error)
+            self._no_verdict(printer_id, f"ML API call failed for printer {printer_id}: {detail}")
             return
 
         detections = payload.get("detections", []) if isinstance(payload, dict) else []
@@ -328,6 +350,7 @@ class ObicoDetectionService:
         # A successful capture + ML call clears any transient error from previous
         # polls (typical case: cold-start RTSP timeout on first frame after startup,
         # followed by healthy polls that otherwise leave the banner stuck in the UI).
+        self._errors.pop(printer_id, None)
         self._last_error = None
 
         # Log every non-safe sample — safe samples would flood history
@@ -371,15 +394,39 @@ class ObicoDetectionService:
 
         Only printers with a running, monitored print have a state entry, so
         consumers get "show nothing" for idle printers for free.
+
+        Four classes, and the two non-verdict ones matter as much as the rest:
+
+        ``error``    the most recent poll produced no verdict. ``error`` carries
+                     the reason — a rejected token, an unreachable ML API, a
+                     camera that would not yield a frame, an unset External URL.
+        ``unknown``  monitored, but no inference has come back yet. The state
+                     entry is created when the print is first seen, which is
+                     before the first capture, so this is the honest answer for
+                     that window.
+        ``safe`` / ``warning`` / ``failure``
+                     an actual verdict from an actual inference.
+
+        This used to default to ``safe`` whenever no verdict had been recorded,
+        so a printer whose detection had never once succeeded rendered exactly
+        like a healthy one: a green badge reading "Safe" at score 0.000. That is
+        the worst possible failure mode for a safety feature — it asserts the
+        print is being watched precisely when it is not (#2952).
         """
-        return {
-            pid: {
-                "class": self._last_class.get(pid, "safe"),
+        result = {}
+        for pid, state in self._states.items():
+            error = self._errors.get(pid)
+            if error:
+                verdict = "error"
+            else:
+                verdict = self._last_class.get(pid) or "unknown"
+            result[pid] = {
+                "class": verdict,
                 "frame_count": state.frame_count,
                 "score": round(state.ewm_mean, 4),
+                "error": error,
             }
-            for pid, state in self._states.items()
-        }
+        return result
 
     def get_status(self, sensitivity: str = "medium") -> dict:
         # Report the thresholds for the configured sensitivity, not a hardcoded

+ 43 - 6
backend/app/services/plate_thumbnail.py

@@ -132,7 +132,17 @@ def _render_model_thumbnails(threemf_bytes: bytes) -> tuple[bytes | None, bytes
     # Local imports so a `import backend.app.services.plate_thumbnail` from
     # an environment without matplotlib/trimesh doesn't fail at import time —
     # the function will simply degrade to no-op via the exception branch.
-    from backend.app.services.stl_thumbnail import _configure_matplotlib_cache
+    #
+    # The light angle is IMPORTED rather than mirrored like the palette above.
+    # "A plate card and a library thumbnail of the same model look alike" is the
+    # whole reason these two renderers share a look, and a second copy of the
+    # angle is exactly how that silently stops being true. A palette can afford a
+    # copy; a number nobody would notice drifting cannot.
+    from backend.app.services.stl_thumbnail import (
+        _configure_matplotlib_cache,
+        _repair_winding,
+        _shade_kwargs,
+    )
 
     _configure_matplotlib_cache()
 
@@ -141,6 +151,7 @@ def _render_model_thumbnails(threemf_bytes: bytes) -> tuple[bytes | None, bytes
     matplotlib.use("Agg")
     import matplotlib.pyplot as plt
     import trimesh
+    from matplotlib.colors import LightSource
     from mpl_toolkits.mplot3d.art3d import Poly3DCollection
 
     loaded = trimesh.load(io.BytesIO(threemf_bytes), file_type="3mf", force="mesh")
@@ -157,6 +168,15 @@ def _render_model_thumbnails(threemf_bytes: bytes) -> tuple[bytes | None, bytes
         except Exception as exc:
             logger.debug("plate_thumbnail: mesh simplification failed, using original: %s", exc)
 
+    # Before the vertices are read, not after: ``scaled`` below is indexed by
+    # ``mesh.faces``, so a repair that ever moves a vertex would leave the two
+    # out of step. Shared with stl_thumbnail rather than copied — the reason
+    # these renderers agree is that they run the same code, not similar code.
+    try:
+        _repair_winding(mesh, trimesh, "plate_thumbnail")
+    except Exception as e:  # best-effort, as the whole module is
+        logger.debug("plate_thumbnail: winding repair skipped (%s)", e)
+
     vertices = mesh.vertices
     bounds_min = vertices.min(axis=0)
     bounds_max = vertices.max(axis=0)
@@ -164,20 +184,36 @@ def _render_model_thumbnails(threemf_bytes: bytes) -> tuple[bytes | None, bytes
     max_extent = (bounds_max - bounds_min).max()
     scaled = centered / max_extent if max_extent > 0 else centered
 
+    # ndarray, not a list of lists — shading walks this to build normals, and the
+    # list form is ~30x slower to construct. Paid twice per plate: once per size.
     faces = mesh.faces
-    poly3d = [[scaled[v] for v in face] for face in faces]
+    poly3d = scaled[faces]
 
-    large = _render_at_size(poly3d, _PLATE_PNG_SIZE, plt, Poly3DCollection)
-    small = _render_at_size(poly3d, _PLATE_PNG_SMALL_SIZE, plt, Poly3DCollection)
+    # Resolved once and shared: both sizes must be lit identically or the 128px
+    # card and the 512px view disagree. Empty for a mesh matplotlib cannot shade,
+    # which keeps such a plate rendering flat instead of failing — see
+    # ``_shade_kwargs``.
+    shade_kw = _shade_kwargs(poly3d, LightSource)
+
+    large = _render_at_size(poly3d, _PLATE_PNG_SIZE, plt, Poly3DCollection, shade_kw)
+    small = _render_at_size(poly3d, _PLATE_PNG_SMALL_SIZE, plt, Poly3DCollection, shade_kw)
     return large, small
 
 
-def _render_at_size(poly3d, size: int, plt, Poly3DCollection) -> bytes:
+def _render_at_size(poly3d, size: int, plt, Poly3DCollection, shade_kw: dict) -> bytes:
     """Render the prepared poly3d collection to an in-memory PNG."""
+    # Local, like every other import in this module, so importing plate_thumbnail
+    # in an environment without matplotlib still works. stl_thumbnail's own
+    # module level is import-light, so this costs nothing after the first call.
+    from backend.app.services.stl_thumbnail import VIEW_AZIM_DEG, VIEW_ELEV_DEG
+
     fig = plt.figure(figsize=(size / 100, size / 100), dpi=100)
     fig.patch.set_facecolor(_BACKGROUND_COLOR)
     ax = fig.add_subplot(111, projection="3d")
     ax.set_facecolor(_BACKGROUND_COLOR)
+    # ``shade=True`` needs a real ``edgecolors``: matplotlib shades the edge
+    # colours alongside the face colours, and an empty array (``"none"``) makes
+    # it raise on the broadcast. Keep the two in step if either moves.
     ax.add_collection3d(
         Poly3DCollection(
             poly3d,
@@ -185,12 +221,13 @@ def _render_at_size(poly3d, size: int, plt, Poly3DCollection) -> bytes:
             edgecolors=_BAMBU_GREEN,
             linewidths=0.1,
             alpha=0.9,
+            **shade_kw,
         )
     )
     ax.set_xlim(-0.6, 0.6)
     ax.set_ylim(-0.6, 0.6)
     ax.set_zlim(-0.6, 0.6)
-    ax.view_init(elev=25, azim=45)
+    ax.view_init(elev=VIEW_ELEV_DEG, azim=VIEW_AZIM_DEG)
     ax.set_axis_off()
     ax.grid(False)
     plt.subplots_adjust(left=0, right=1, top=1, bottom=0)

+ 54 - 7
backend/app/services/print_batch.py

@@ -114,6 +114,10 @@ class PlateProgress:
     actual_cost: float | None = None
     filament_used_grams: float | None = None
     print_time_seconds: int = 0
+    # Whether any queue item for this plate still exists, in any status.
+    # Dispatch clones one to inherit the print configuration, so a plate with
+    # none cannot be queued however many runs it still owes.
+    has_source: bool = False
 
     @property
     def dispatched(self) -> int:
@@ -123,6 +127,11 @@ class PlateProgress:
     def remaining(self) -> int:
         return max(0, self.quantity_target - self.dispatched)
 
+    @property
+    def can_dispatch(self) -> bool:
+        """True when this plate owes runs *and* something can produce them."""
+        return self.remaining > 0 and self.has_source
+
     @property
     def cost_per_run(self) -> float | None:
         """Observed mean cost of this plate's completed runs, or None.
@@ -185,6 +194,16 @@ class BatchProgress:
     def remaining(self) -> int:
         return self._sum("remaining")
 
+    @property
+    def dispatchable_remaining(self) -> int:
+        """Of the runs still owed, how many can actually be queued.
+
+        Lower than ``remaining`` when a plate's last queue item was deleted:
+        the order still owes the run, but nothing survives to clone its
+        printer target, AMS mapping and print options from.
+        """
+        return sum(p.remaining for p in self.plates if p.has_source)
+
     @property
     def actual_cost(self) -> float | None:
         costs = [p.actual_cost for p in self.plates if p.actual_cost is not None]
@@ -282,6 +301,11 @@ async def load_progress(db: AsyncSession, batch: PrintBatch) -> BatchProgress:
                 plate.quantity_target += count
         elif not progress.has_targets and status in CONSUMING_STATUSES:
             plate.quantity_target += count
+        # Any surviving row is a clone source, whatever its status — a
+        # cancelled or failed run still carries the configuration a re-queue
+        # needs. Set before the status filter below so a status this module
+        # has no counter for still marks the plate dispatchable.
+        plate.has_source = True
         if status in COUNTED_STATUSES:
             setattr(plate, status, getattr(plate, status) + count)
         else:
@@ -462,6 +486,18 @@ def _clone_queue_item(source: PrintQueueItem, *, position: int, created_by_id: i
     return clone
 
 
+def _plate_label(plate: PlateProgress) -> str:
+    """How a plate is named in an error the user reads.
+
+    Prefers the plate name the order stored, because that is what the Batches
+    tab shows; falls back to the plate number for orders created before names
+    were recorded.
+    """
+    if plate.plate_name:
+        return plate.plate_name
+    return f"Plate {plate.plate_id if plate.plate_id is not None else 1}"
+
+
 async def dispatch_remaining(
     db: AsyncSession,
     batch: PrintBatch,
@@ -478,9 +514,12 @@ async def dispatch_remaining(
     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.
+    Raises :class:`BatchDispatchError` when nothing at all can be produced
+    because no plate that owes runs has an item to clone — the order can
+    describe work whose every run has since been deleted, and there is no
+    configuration to copy in that case. A plate in that state is skipped
+    rather than aborting the whole order: one unrecoverable plate must not
+    block the plates that are still perfectly dispatchable.
     """
     progress = await load_progress(db, batch)
     if not progress.has_targets:
@@ -491,6 +530,7 @@ async def dispatch_remaining(
         targets = [p for p in targets if p.plate_id == plate_id]
 
     created: list[PrintQueueItem] = []
+    stranded: list[PlateProgress] = []
 
     for plate in targets:
         if limit is not None and len(created) >= limit:
@@ -508,10 +548,8 @@ async def dispatch_remaining(
         ).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."
-            )
+            stranded.append(plate)
+            continue
 
         wanted = plate.remaining
         if limit is not None:
@@ -533,11 +571,20 @@ async def dispatch_remaining(
                 db.add(cloned_variant)
             created.append(clone)
 
+    if not created and stranded:
+        names = ", ".join(_plate_label(p) for p in stranded)
+        raise BatchDispatchError(
+            f"{names} {'have' if len(stranded) > 1 else 'has'} no queued or finished run to copy settings from. "
+            "Queue the plate once from the file, then dispatch the rest from here."
+        )
+
     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)
 
+    if stranded:
+        logger.info("Batch %s: skipped %d plate(s) with no item to clone", batch.id, len(stranded))
     logger.info("Dispatched %d item(s) for batch %s", len(created), batch.id)
     return created

+ 6 - 1
backend/app/services/print_cost_estimate.py

@@ -12,6 +12,7 @@ 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.services.inventory_mode import spoolman_owns_assignments
 from backend.app.utils import threemf_tools
 from backend.app.utils.safe_path import safe_join_under
 
@@ -135,7 +136,11 @@ async def estimate_queue_source_cost(
     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:
+    # Built-in spool prices only. In Spoolman mode the built-in table may still
+    # hold rows from before the user switched -- nothing clears it since #2812 --
+    # and pricing an estimate from a spool the printer is not drawing on would
+    # be worse than the default rate this falls back to.
+    if printer_id is not None and mapping and not await spoolman_owns_assignments(db):
         assignments = (
             (
                 await db.execute(

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 676 - 78
backend/app/services/print_scheduler.py


+ 152 - 11
backend/app/services/print_storage.py

@@ -7,10 +7,9 @@ FTPS on port 990. On every Bambu model that port serves **external storage only*
 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.
+The dispatch says where it went: 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
@@ -19,6 +18,19 @@ 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).
 
+But that URL is not the last word on reachability, and reading it as one was
+itself a regression (#2856). It says where the printer *chose* to put the file,
+not whether port 990 can serve it -- measured on an H2D (firmware 01.03.00.00,
+card in the slot): every ``brtc://emmc/<name>`` print of that reporter's was
+sitting under ``/cache/<name>`` and downloaded fine, 19 MB included, until this
+module started skipping the lookup. On #2780's P2S and H2C the same URL really
+did mean nothing was there. So an internal-storage URL earns a *bounded probe*
+rather than a skip: it names the exact file, which turns the 110-connection
+sweep into one connection walking five paths, and the answer comes from the
+printer instead of from a guess about its model. Only when that probe misses
+does the verdict's ``reason`` stand -- see :func:`probe_filename_from_url` and
+:func:`ftp_probe_paths`, and the callers that run it.
+
 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
@@ -30,28 +42,75 @@ 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.
+# The scheme that means "uploaded to external storage, reachable over FTPS".
+# An unknown scheme -- whatever Bambu ships next -- must not read as fine, so
+# this matches the reachable value rather than the unreachable one.
 _EXTERNAL_STORAGE_SCHEME = "ftp"
 
+# ``file://`` means the file was already on the printer when the print started:
+# a reprint from the touchscreen, from Handy, or a Studio send-to-storage
+# followed by a print. The path says which storage, and only the printer's own
+# internal roots are out of reach of port 990. Measured on an H2D, 2026-08-17:
+# ``file:///media/usb0/foobar.gcode.3mf`` while that exact file was listable and
+# downloadable over FTPS.
+_LOCAL_FILE_SCHEME = "file"
+
+# Internal roots seen in ``file://`` paths. ``/userdata`` is where the model
+# cache lives (``/userdata/model/history/<name>``, confirmed via the printer's
+# own file listing), and port 990 does not serve it.
+_INTERNAL_FILE_PREFIXES = ("/userdata/",)
+
 # 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"
 
+# Same verdict as REASON_INTERNAL_STORAGE, different cause -- and the cause is
+# the whole of the advice. `brtc://emmc/<name>` is a *dispatch* that chose
+# internal storage: a slicer sent the file and the printer filed it where port
+# 990 cannot serve it, which the operator can change by sending it elsewhere.
+# `file:///userdata/...` is a print of a file that was already on the printer --
+# a touchscreen re-print, a Handy start, a Studio send-to-storage printed later
+# -- so there was no dispatch to aim anywhere, and telling that operator to pick
+# "External" in Send describes a step they never took (#1820).
+REASON_INTERNAL_HISTORY = "internal_history"
+
+# Not a storage verdict — the file's location was never in question. The
+# printer's FTPS service was inside its post-failed-handshake cool-off when the
+# print started, so the sweep was skipped without a single connection. Stamped
+# on the fallback archive by the print-start handler rather than returned by
+# `_verdict`, and unlike the two above it is temporary: it is the one reason a
+# retry is worth scheduling (#2957).
+REASON_FTPS_COOLOFF = "ftps_cooloff"
+
+# Where a sliced file has ever been found over FTPS, in the order the sweep in
+# `main.py` tries them -- root first, which is where A1/P1-series uploads land
+# (#972), then `/cache`, which is where the H2D keeps its copy of an eMMC job
+# (#2856).
+_PROBE_DIRECTORIES = ("/", "/cache/", "/model/", "/data/", "/data/Metadata/")
+
+# Longest name worth probing for. Every filesystem the printer could be serving
+# from caps a name at 255 bytes, so anything past this cannot be a file that is
+# actually there -- and it would be written to a local temp path too.
+_MAX_PROBE_FILENAME_LENGTH = 255
+
 
 @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.
+
+    ``probe_filename`` is the exact name the dispatch gave, present only on an
+    unreachable verdict and only when the URL named a ``.3mf``. It is the
+    caller's chance to check the claim cheaply before acting on ``reason`` --
+    see :func:`ftp_probe_paths`.
     """
 
     reachable: bool
     reason: str | None = None
+    probe_filename: str | None = None
 
 
 _REACHABLE = StorageVerdict(reachable=True)
@@ -68,13 +127,74 @@ def url_is_external_storage(project_url: str | None) -> bool | None:
     # string is not an answer.
     if not isinstance(project_url, str) or not project_url:
         return None
-    scheme, separator, _ = project_url.partition("://")
+    scheme, separator, path = 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
+    scheme = scheme.lower()
+    if scheme == _EXTERNAL_STORAGE_SCHEME:
+        return True
+    if scheme == _LOCAL_FILE_SCHEME:
+        # Only a known-internal path is positive evidence of somewhere FTPS
+        # cannot reach. Anything else is unknown, which sweeps -- this module
+        # skips only on positive evidence, and a path we do not recognise is
+        # not that. Returning False here instead is what made a print of a file
+        # sitting on the stick report as internal storage and archive with no
+        # 3MF, when the sweep would have found it immediately.
+        if path.startswith(_INTERNAL_FILE_PREFIXES):
+            return False
+        return None
+    return False
+
+
+def probe_filename_from_url(project_url: str | None) -> str | None:
+    """The exact 3MF name *project_url* points at, for a bounded FTPS probe.
+
+    ``brtc://emmc/Cube.gcode.3mf`` -> ``Cube.gcode.3mf``, and likewise for the
+    internal ``file://`` paths. ``None`` when there is no name to probe with,
+    which is the caller's signal to fall back to the sweep it would have run.
+
+    Only ``.3mf`` names come back. A print running from a bare gcode has no 3MF
+    to find at any path, so probing for one would spend connections to learn
+    what the extension already said.
+
+    The value arrives from the network -- whatever the slicer or the printer
+    put in the dispatch -- and callers turn it into both a remote path and a
+    local temp filename, so anything that could steer either is refused rather
+    than sanitized: no separators, no traversal, no control characters.
+    """
+    if not isinstance(project_url, str):
+        return None
+    _scheme, separator, path = project_url.partition("://")
+    if not separator:
+        return None
+    name = path.rpartition("/")[2].strip()
+    if not name or len(name) > _MAX_PROBE_FILENAME_LENGTH:
+        return None
+    # A leading dot is either a traversal segment or a hidden file; neither is
+    # a sliced upload, and both would put an odd path on the wire. A backslash
+    # is a path separator on the host even though it is a legal character in
+    # the printer's own filesystem, which is how a name could reach outside the
+    # temp directory it is written to.
+    if name.startswith(".") or "\\" in name:
+        return None
+    if any(character < " " or character == "\x7f" for character in name):
+        return None
+    if not name.lower().endswith(".3mf"):
+        return None
+    return name
+
+
+def ftp_probe_paths(filename: str) -> list[str]:
+    """Remote paths to try for *filename*, best first.
+
+    One filename across the known directories, because the dispatch already
+    told us the name and only the directory is in question (#2856). Callers
+    walk the list over a single connection, against the sweep's ~110.
+    """
+    return [f"{directory}{filename}" for directory in _PROBE_DIRECTORIES]
 
 
 def external_storage_present(state: object | None) -> bool:
@@ -120,6 +240,18 @@ def last_print_storage_verdict(state: object | None) -> StorageVerdict:
     return _verdict(getattr(state, "last_project_url", None), state)
 
 
+def _internal_reason(project_url: str | None) -> str:
+    """Which flavour of "internal" *project_url* names.
+
+    Only ever reached on a negative verdict, so the URL is one of the two
+    shapes :func:`url_is_external_storage` answers False for.
+    """
+    if not isinstance(project_url, str):
+        return REASON_INTERNAL_STORAGE
+    scheme = project_url.partition("://")[0].lower()
+    return REASON_INTERNAL_HISTORY if scheme == _LOCAL_FILE_SCHEME else REASON_INTERNAL_STORAGE
+
+
 def _verdict(project_url: str | None, state: object | None) -> StorageVerdict:
     if state is None:
         return _REACHABLE
@@ -128,7 +260,16 @@ def _verdict(project_url: str | None, state: object | None) -> StorageVerdict:
     # named the destination.
     external = url_is_external_storage(project_url)
     if external is False:
-        return StorageVerdict(reachable=False, reason=REASON_INTERNAL_STORAGE)
+        # Worth probing only if there is external storage for the probe to find
+        # anything on. An empty slot answers the question the probe would ask,
+        # and #2780's H2C sat that way for three weeks -- one connection per
+        # print start is small, but it is not worth spending to be told what
+        # the printer already said.
+        return StorageVerdict(
+            reachable=False,
+            reason=_internal_reason(project_url),
+            probe_filename=probe_filename_from_url(project_url) if external_storage_present(state) else None,
+        )
     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

+ 80 - 7
backend/app/services/printer_diagnostic.py

@@ -17,11 +17,17 @@ import ssl
 
 from backend.app.models.printer import Printer
 from backend.app.schemas.printer import DiagnosticCheck, PrinterDiagnosticResult
+from backend.app.services.bambu_ftp import find_remote_file_async
 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.print_storage import (
+    REASON_INTERNAL_STORAGE,
+    StorageVerdict,
+    ftp_probe_paths,
+    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
 
@@ -35,6 +41,12 @@ PORT_CHAMBER_IMAGE = 6000  # Chamber image protocol — A1/P1 camera stream; opt
 
 _PORT_PROBE_TIMEOUT = 3.0
 
+# Cap for the storage probe (#2856). One connection and a handful of directory
+# listings, so a healthy printer answers in well under a second. Kept short on
+# purpose: this check sits inside the support bundle's 15s-per-printer budget,
+# and in the interactive run it is spinner time the user is watching.
+_STORAGE_PROBE_TIMEOUT = 6.0
+
 # Default seconds the `printer_publishing` check will wait for the first
 # report-topic message before declaring fail. Bambu printers in idle publish
 # push_status every few seconds; 10s catches healthy bridges with margin while
@@ -116,6 +128,53 @@ async def _check_ftps_tls(ip: str, model: str | None, timeout: float = _PORT_PRO
                 pass
 
 
+async def _last_print_file_is_reachable(
+    printer: Printer | None,
+    verdict: StorageVerdict,
+    *,
+    ftps_ok: bool,
+) -> bool:
+    """Did the last print's file turn up on external storage after all? (#2856)
+
+    ``verdict`` is read off the dispatch URL, which says where the printer
+    *put* the file, not whether port 990 can serve it: an H2D with a card in
+    the slot reports ``brtc://emmc/<name>`` and then hands the same file over
+    from ``/cache`` without complaint. Warning that the file is out of reach
+    while that user's archives are quietly complete would send them chasing a
+    setting that is already right.
+
+    So check before saying it -- one connection, one listing per candidate
+    directory, no transfer, and only for the check that is about to warn.
+    "Could not check" returns False and leaves the warning standing, which is
+    the safe direction: it is a warn, not a fail.
+    """
+    if not ftps_ok or not verdict.probe_filename or printer is None:
+        return False
+    # Attribute reads inside the guard too: this also runs from the support
+    # bundle, where the row may outlive its session, and a detached-instance
+    # error there is "could not check", not a broken diagnostic.
+    ip_address = None
+    try:
+        ip_address = getattr(printer, "ip_address", None)
+        access_code = getattr(printer, "access_code", None)
+        if not ip_address or not access_code:
+            return False
+        found = await find_remote_file_async(
+            ip_address,
+            access_code,
+            ftp_probe_paths(verdict.probe_filename),
+            timeout=_STORAGE_PROBE_TIMEOUT,
+            socket_timeout=_STORAGE_PROBE_TIMEOUT,
+            printer_model=getattr(printer, "model", None),
+        )
+    except Exception as e:
+        logger.debug("Could not probe %s for %s: %s", ip_address, verdict.probe_filename, e)
+        return False
+    if found:
+        logger.debug("Last print file is on external storage at %s despite %s", found, verdict.reason)
+    return found is not None
+
+
 def _auth_reason_params(reason: str | None) -> dict:
     """Map a client's CONNACK-refusal slug onto the check's `params.reason`.
 
@@ -329,18 +388,32 @@ async def run_connection_diagnostic(
                 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.
+    elif not (last_verdict := last_print_storage_verdict(state)).reachable and not await _last_print_file_is_reachable(
+        printer, last_verdict, ftps_ok=ftps_state == "ok"
+    ):
+        # The toggle is on, a card is in, the printer said the last print's file
+        # is on internal storage — and a probe confirmed it really is out of
+        # reach. That 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.
+        #
+        # The verdict's own reason, not a fixed one: a print started from the
+        # printer's screen reaches this branch too, and it never involved a
+        # slicer, so the advice attached to REASON_INTERNAL_STORAGE would name a
+        # dialog its operator never opened (#1820).
         checks.append(
             DiagnosticCheck(
                 id="external_storage",
                 status="warn",
-                params={"reason": REASON_INTERNAL_STORAGE},
+                params={"reason": last_verdict.reason or REASON_INTERNAL_STORAGE},
             )
         )
+    elif not last_verdict.reachable:
+        # Reached only when the probe above found the file: the printer named
+        # internal storage and served it over FTPS anyway. What this check is
+        # for is whether Bambuddy can read the print file, and it demonstrably
+        # can — so pass, whatever the toggle happens to say (#2856).
+        checks.append(DiagnosticCheck(id="external_storage", status="pass"))
     elif store_to_sdcard is True:
         checks.append(DiagnosticCheck(id="external_storage", status="pass"))
     elif store_to_sdcard is False:

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

@@ -8,7 +8,14 @@ from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.models.printer import Printer
-from backend.app.services.bambu_mqtt import BambuMQTTClient, MQTTLogEntry, PrinterState, get_stage_name
+from backend.app.services.bambu_mqtt import (
+    STAGE_NAMES,
+    BambuMQTTClient,
+    MQTTLogEntry,
+    PrinterState,
+    get_stage_name,
+)
+from backend.app.utils.kprofile_lookup import build_slot_k_resolver
 
 logger = logging.getLogger(__name__)
 
@@ -398,12 +405,20 @@ class PrinterManager:
         self._clients: dict[int, BambuMQTTClient] = {}
         self._models: dict[int, str | None] = {}  # Cache printer models for feature detection
         self._printer_info: dict[int, PrinterInfo] = {}  # Cache printer name/serial for callbacks
+        # Last AMS / external-spool reading of a printer whose client has been
+        # dropped, so the queue can still tell which machine holds which colour
+        # (#2876). Deliberately outside the client's own state: it answers
+        # "what did this printer last have loaded", not "what is it reporting
+        # now", and the two must not be confused by anything that displays or
+        # merges live status.
+        self._last_trays: dict[int, dict] = {}
         self._on_print_start: Callable[[int, dict], None] | None = None
         self._on_print_complete: Callable[[int, dict], None] | None = None
         self._on_print_running_observed: Callable[[int, dict], None] | None = None
         self._on_finish_photo_moment: Callable[[int, dict], None] | None = None
         self._on_status_change: Callable[[int, PrinterState], None] | None = None
         self._on_ams_change: Callable[[int, list], None] | None = None
+        self._on_fts_inlet_change: Callable[[int, int, str], None] | None = None
         self._on_layer_change: Callable[[int, int], None] | None = None
         self._on_print_progress: Callable[[int, int], None] | None = None
         self._on_bed_temp_update: Callable[[int, float], None] | None = None
@@ -492,7 +507,14 @@ class PrinterManager:
         """
         printer = self.get_printer(printer_id)
         if not printer:
-            return
+            # No cached info means no client is registered — the printer was
+            # disconnected outright rather than merely powered off. The gate is
+            # still releasable from the API in that state (#2864), and a retained
+            # MQTT topic left saying "awaiting" would outlive the truth, so fall
+            # back to the row rather than dropping the emission.
+            printer = await self._printer_info_from_db(printer_id)
+            if not printer:
+                return
 
         try:
             from backend.app.services.mqtt_relay import mqtt_relay
@@ -515,6 +537,20 @@ class PrinterManager:
         except Exception as e:
             logger.warning("Failed to send plate-clear notification for printer %d: %s", printer_id, e)
 
+    async def _printer_info_from_db(self, printer_id: int) -> PrinterInfo | None:
+        """Name and serial for a printer with no registered client."""
+        from backend.app.core.database import async_session
+
+        try:
+            async with async_session() as db:
+                row = (
+                    await db.execute(select(Printer.name, Printer.serial_number).where(Printer.id == printer_id))
+                ).first()
+        except Exception as e:
+            logger.warning("Failed to load printer %d info from DB: %s", printer_id, e)
+            return None
+        return PrinterInfo(row[0], row[1]) if row else None
+
     async def _broadcast_status_change(self, printer_id: int) -> None:
         """Emit a ``printer_status`` WebSocket update for this printer (#1128).
 
@@ -626,6 +662,14 @@ class PrinterManager:
         """Set callback for AMS data change events."""
         self._on_ams_change = callback
 
+    def set_fts_inlet_change_callback(self, callback: Callable[[int, int, str], None]):
+        """Set callback for Filament Track Switch inlet moves.
+
+        Receives ``(printer_id, ams_id, inlet)``. Fired only when an AMS moves
+        between inlets, not on the first sighting of a binding.
+        """
+        self._on_fts_inlet_change = callback
+
     def set_layer_change_callback(self, callback: Callable[[int, int], None]):
         """Set callback for layer change events. Receives (printer_id, layer_num)."""
         self._on_layer_change = callback
@@ -689,6 +733,29 @@ class PrinterManager:
 
             future.add_done_callback(handle_exception)
 
+    def last_known_trays(self, printer_id: int) -> dict:
+        """What this printer last had loaded, for a printer with no live client.
+
+        Only the tray keys, and only as history: a caller that wants to know
+        what a printer is reporting *now* must use :meth:`get_status`. This
+        exists because dropping a client drops its status with it, and the
+        queue reads the loaded filament to decide which offline printer is
+        worth switching on (#2876) — ``_power_on_and_wait`` replaces the client
+        on every attempt, so without this each attempt erased the reading the
+        next one needs.
+        """
+        return self._last_trays.get(printer_id, {})
+
+    def _remember_trays(self, printer_id: int) -> None:
+        """Keep the tray reading of a client that is about to be dropped."""
+        client = self._clients.get(printer_id)
+        if not client:
+            return
+        raw = client.state.raw_data or {}
+        remembered = {key: raw[key] for key in ("ams", "vt_tray") if raw.get(key)}
+        if remembered:
+            self._last_trays[printer_id] = remembered
+
     async def connect_printer(self, printer: Printer) -> bool:
         """Connect to a printer."""
         if printer.id in self._clients:
@@ -720,6 +787,10 @@ class PrinterManager:
             if self._on_ams_change:
                 self._schedule_async(self._on_ams_change(printer_id, ams_data))
 
+        def on_fts_inlet_change(ams_id: int, inlet: str):
+            if self._on_fts_inlet_change:
+                self._schedule_async(self._on_fts_inlet_change(printer_id, ams_id, inlet))
+
         def on_layer_change(layer_num: int):
             if self._on_layer_change:
                 self._schedule_async(self._on_layer_change(printer_id, layer_num))
@@ -753,6 +824,7 @@ class PrinterManager:
             on_print_start=on_print_start,
             on_print_complete=on_print_complete,
             on_ams_change=on_ams_change,
+            on_fts_inlet_change=on_fts_inlet_change,
             on_layer_change=on_layer_change,
             on_print_progress=on_print_progress,
             on_bed_temp_update=on_bed_temp_update,
@@ -775,6 +847,7 @@ class PrinterManager:
     def disconnect_printer(self, printer_id: int, timeout: float = 0):
         """Disconnect from a printer."""
         if printer_id in self._clients:
+            self._remember_trays(printer_id)
             self._clients[printer_id].disconnect(timeout=timeout)
             del self._clients[printer_id]
         self._models.pop(printer_id, None)  # Clean up model cache
@@ -1103,6 +1176,23 @@ def get_derived_status_name(state: PrinterState, model: str | None = None) -> st
     # X1 models use -1 for idle, A1/P1 models use 255 for idle
     # Valid stage numbers are 0-254
     if 0 <= state.stg_cur < 255:
+        # A stage number the table does not cover is named "Preparing" rather
+        # than "Unknown stage (72)". New models report stages before Bambuddy
+        # learns their names -- the H2C still has several -- and the card is
+        # the wrong place to say so: the number means nothing to the person
+        # reading it, and every stage that has ever turned out to be unnamed
+        # was part of the run-up to printing, so "Preparing" is both the more
+        # useful answer and the more likely one.
+        #
+        # This is display only, and deliberately not pushed down into
+        # `get_stage_name`. That function also feeds the stage-transition log
+        # line and the once-per-session warning that exists precisely to
+        # capture unnamed stages so they can be named later (bambu_mqtt.py
+        # ~4100) -- there the number is the entire diagnostic value, and
+        # replacing it with "Preparing" would hide the very thing that
+        # reports these.
+        if state.stg_cur not in STAGE_NAMES:
+            return "Preparing"
         return get_stage_name(state.stg_cur)
 
     # If not in RUNNING state, no derived status needed
@@ -1264,14 +1354,11 @@ def printer_state_to_dict(
     vt_tray = []
     raw_data = state.raw_data or {}
 
-    # Build K-profile lookup map: cali_idx -> k_value
-    kprofile_map: dict[int, float] = {}
-    for kp in state.kprofiles or []:
-        if kp.slot_id is not None and kp.k_value:
-            try:
-                kprofile_map[kp.slot_id] = float(kp.k_value)
-            except (ValueError, TypeError):
-                pass  # Skip K-profile entries with unparseable values
+    # K value for a slot's bound profile. Shared with the REST serializer of
+    # the same card (routes/printers.py) so the two cannot answer differently:
+    # this one used to key on cali_idx alone, which on a dual-nozzle machine
+    # meant whichever nozzle's table was listed last won the slot.
+    resolve_slot_k = build_slot_k_resolver(state)
 
     if "ams" in raw_data and isinstance(raw_data["ams"], list):
         for ams_data in raw_data["ams"]:
@@ -1287,8 +1374,8 @@ def printer_state_to_dict(
                 # Get K value: first try tray's k field, then lookup from K-profiles
                 k_value = tray.get("k")
                 cali_idx = tray.get("cali_idx")
-                if k_value is None and cali_idx is not None and cali_idx in kprofile_map:
-                    k_value = kprofile_map[cali_idx]
+                if k_value is None:
+                    k_value = resolve_slot_k(cali_idx, int(ams_data.get("id", 0)), int(tray.get("id", 0)))
 
                 # P1S / A1 Mini physically-empty-slot signal (#1322 follow-up by
                 # @RosdasHH): for a truly empty slot the firmware sends only
@@ -1420,8 +1507,11 @@ def printer_state_to_dict(
             # Get K value for vt_tray
             vt_k_value = vt_data.get("k")
             vt_cali_idx = vt_data.get("cali_idx")
-            if vt_k_value is None and vt_cali_idx is not None and vt_cali_idx in kprofile_map:
-                vt_k_value = kprofile_map[vt_cali_idx]
+            if vt_k_value is None:
+                # External holder: id 254 is Ext-L, 255 is Ext-R. The resolver
+                # takes the 0/1 tray index, so normalise before asking.
+                vt_id = int(vt_data.get("id", 254))
+                vt_k_value = resolve_slot_k(vt_cali_idx, 255, vt_id - 254 if vt_id >= 254 else vt_id)
 
             tray_id = int(vt_data.get("id", 254))
             vt_tray.append(
@@ -1473,6 +1563,10 @@ def printer_state_to_dict(
                 "actions": e.actions,
                 "job_id": e.job_id,
                 "full_code": e.full_code,
+                # Same field as the status response carries (#2926) — a relay
+                # watching the stream should not have to poll REST to find out
+                # what a fault means.
+                "description": e.description,
             }
             for e in (state.hms_errors or [])
         ],
@@ -1507,6 +1601,53 @@ def printer_state_to_dict(
         ),
         # Per-AMS extruder map: {ams_id: extruder_id} where 0=right, 1=left
         "ams_extruder_map": ams_extruder_map,
+        # Filament Track Switch. Both fields have to travel on the WebSocket, not
+        # only on the REST status: the frontend shallow-merges each push over its
+        # cached status, so a field that is absent here keeps whatever the last
+        # full fetch left behind. Omitting them meant the AMS inlet badges only
+        # ever changed on a page reload.
+        "fila_switch": (
+            {
+                "installed": True,
+                "in_slots": list(state.fila_switch.in_slots),
+                "out_extruders": list(state.fila_switch.out_extruders),
+                "stat": state.fila_switch.stat,
+                "info": state.fila_switch.info,
+                # Mirrors BambuStudio's DevFilaSwitch::IsReady — every AMS has to
+                # be bound to an inlet before the switch can route anything. Until
+                # the operator has done that on the printer's Manual AMS Setup
+                # screen, Studio refuses a load outright rather than sending a
+                # command the firmware cannot act on, and so do we.
+                # An empty AMS list is "ready", as it is in Studio: there is then
+                # no slot to load from, so nothing can reach the check anyway, and
+                # reporting not-ready would only mean a confusing toast on a
+                # payload that has not carried the AMS block yet.
+                #
+                # An AMS still reporting a real extruder id rather than 0xE has no
+                # inlet entry, so a machine with one hard-wired unit reads as not
+                # ready. That looks harsh but is exactly Studio's own rule —
+                # IsReady() requires a switcher position on *every* AMS, and only
+                # the 0xE branch ever sets one (DevFilaSystem.cpp:596-615).
+                "ready": all(str(u["id"]) in state.ams_switch_inlet for u in ams_units),
+            }
+            if state.fila_switch and state.fila_switch.installed
+            else None
+        ),
+        # Per-AMS FTS inlet binding: {ams_id: "A" | "B"}. Gated on the accessory
+        # so a stale binding cannot outlive it being unplugged.
+        "ams_switch_inlet": (dict(state.ams_switch_inlet) if state.fila_switch and state.fila_switch.installed else {}),
+        # Which AMS slot each hotend is fed from: {extruder_id: {...}}. Travels on
+        # the WebSocket for the same reason as fila_switch above — the frontend
+        # shallow-merges pushes over its cached status, so an absent field keeps a
+        # stale value forever. Empty on printers that do not report it.
+        "extruder_slots": {
+            str(ext_id): {
+                "ams_id": slot.ams_id,
+                "slot_id": slot.slot_id,
+                "has_filament": slot.has_filament,
+            }
+            for ext_id, slot in state.extruder_slots.items()
+        },
         # WiFi signal strength
         "wifi_signal": state.wifi_signal,
         "wired_network": state.wired_network,

+ 728 - 0
backend/app/services/printer_media.py

@@ -0,0 +1,728 @@
+"""Helpers for matching and downloading printer-side video files."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import re
+import secrets
+import shutil
+import tempfile
+import time
+import zipfile
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass, replace
+from datetime import datetime, timedelta, timezone
+from pathlib import Path, PurePosixPath
+
+from backend.app.core.config import settings
+from backend.app.core.tasks import spawn_background_task
+from backend.app.services.bambu_ftp import (
+    DownloadCancelled,
+    DownloadInsufficientSpace,
+    DownloadLimitExceeded,
+    download_file_async,
+)
+
+logger = logging.getLogger(__name__)
+
+VIDEO_SUFFIXES = (".mp4", ".avi", ".mkv")
+MAX_PRINTER_ZIP_BYTES = 10 * 1024**3
+PRINTER_ZIP_FREE_SPACE_RESERVE = 256 * 1024**2
+_STALE_BUNDLE_SECONDS = 60 * 60
+MAX_PRINTER_ZIP_PREPARE_SECONDS = 30 * 60
+MAX_OPEN_ARCHIVE_IPCAM_SECONDS = 24 * 60 * 60
+_BUNDLE_KEY_RE = re.compile(r"^[A-Za-z0-9_-]{1,200}$")
+_JOB_KEY_RE = re.compile(r"^[A-Za-z0-9_-]{20,200}$")
+_LOCAL_JOB_TASKS: dict[str, asyncio.Task] = {}
+_cleanup_task: asyncio.Task | None = None
+_CLEANUP_INTERVAL_SECONDS = 15 * 60
+
+
+class PrinterFilesZipTooLargeError(ValueError):
+    """The selected printer files exceed the bounded ZIP staging limit."""
+
+
+class PrinterFilesZipInsufficientSpaceError(OSError):
+    """The app data volume cannot safely stage the selected files."""
+
+
+@dataclass(frozen=True)
+class PrinterFilesZipResult:
+    """Result of staging one printer ZIP."""
+
+    path: Path
+    requested: int
+    successful: int
+    failed_paths: tuple[str, ...]
+    total_bytes: int
+
+
+@dataclass(frozen=True)
+class PrinterFilesJobStatus:
+    """Serializable state for an asynchronous browser preparation job."""
+
+    job_id: str
+    printer_id: int
+    state: str
+    requested: int
+    successful: int = 0
+    failed: int = 0
+    token: str | None = None
+    filename: str | None = None
+    message: str | None = None
+
+
+class _FileCancelSignal:
+    """Cross-worker cancellation signal checked by the FTP callback thread."""
+
+    def __init__(self, path: Path):
+        self.path = path
+        self._last_check = 0.0
+        self._cached = False
+
+    def is_set(self) -> bool:
+        if self._cached:
+            return True
+        now = time.monotonic()
+        if now - self._last_check >= 0.25:
+            self._last_check = now
+            self._cached = self.path.exists()
+        return self._cached
+
+
+def _job_status_path(job_id: str) -> Path:
+    if not _JOB_KEY_RE.fullmatch(job_id):
+        raise ValueError("Invalid printer download job id")
+    return _printer_zip_root() / f"job-{job_id}.json"
+
+
+def _job_cancel_path(job_id: str) -> Path:
+    if not _JOB_KEY_RE.fullmatch(job_id):
+        raise ValueError("Invalid printer download job id")
+    return _printer_zip_root() / f"job-{job_id}.cancel"
+
+
+def _write_job_status(status: PrinterFilesJobStatus) -> None:
+    """Atomically publish job state for polling from any app worker."""
+
+    path = _job_status_path(status.job_id)
+    temp_path = path.with_suffix(".tmp")
+    temp_path.write_text(json.dumps(status.__dict__, separators=(",", ":")), encoding="utf-8")
+    temp_path.replace(path)
+
+
+def _read_job_status(job_id: str) -> PrinterFilesJobStatus | None:
+    try:
+        data = json.loads(_job_status_path(job_id).read_text(encoding="utf-8"))
+        return PrinterFilesJobStatus(**data)
+    except (FileNotFoundError, OSError, ValueError, TypeError, json.JSONDecodeError):
+        return None
+
+
+def _naive_utc(value: datetime | None) -> datetime | None:
+    if value is None:
+        return None
+    if value.tzinfo is not None:
+        return value.astimezone(timezone.utc).replace(tzinfo=None)
+    return value
+
+
+def match_ipcam_chunks(
+    files: list[dict],
+    started_at: datetime | None,
+    completed_at: datetime | None,
+    *,
+    now: datetime | None = None,
+) -> list[dict]:
+    """Return `/ipcam` chunks whose completion time overlaps a print.
+
+    Bambu's `ipcam-record.*.mp4` files are fixed-size chunks. On the tested X1C
+    and H2D firmware, their FTP mtime is the chunk completion time in the same
+    UTC-naive basis used by archive timestamps. Some firmware reports FTP LIST
+    mtimes in printer-local time instead; LIST carries no timezone with which
+    to correct those values reliably. A ten-minute tail includes the final
+    chunk, whose mtime lands after the print-complete event.
+    """
+
+    start = _naive_utc(started_at)
+    if start is None:
+        return []
+    live_end = _naive_utc(now) or datetime.now(timezone.utc).replace(tzinfo=None)
+    # A crash can leave an archive in ``printing`` indefinitely. Do not turn
+    # that stale row into a window covering every chunk created since then.
+    end = _naive_utc(completed_at) or min(live_end, start + timedelta(seconds=MAX_OPEN_ARCHIVE_IPCAM_SECONDS))
+    lower = start - timedelta(minutes=1)
+    upper = max(start, end) + timedelta(minutes=10)
+
+    matches: list[dict] = []
+    for file in files:
+        name = str(file.get("name") or "")
+        mtime = file.get("mtime")
+        if file.get("is_directory") or not name.lower().startswith("ipcam-record."):
+            continue
+        if not name.lower().endswith(VIDEO_SUFFIXES) or not isinstance(mtime, datetime):
+            continue
+        timestamp = _naive_utc(mtime)
+        if timestamp is not None and lower <= timestamp <= upper:
+            matches.append(file)
+
+    matches.sort(key=lambda item: _naive_utc(item.get("mtime")) or datetime.min)
+    return matches
+
+
+def _zip_arcname(remote_path: str, used: set[str]) -> str:
+    """Return a safe, unique relative archive name for a printer path."""
+
+    parts = [part for part in PurePosixPath(remote_path).parts if part not in ("/", "", ".", "..")]
+    candidate = "/".join(parts) or "printer-file"
+    stem = candidate
+    suffix = ""
+    if "." in PurePosixPath(candidate).name:
+        suffix = "".join(PurePosixPath(candidate).suffixes)
+        stem = candidate[: -len(suffix)] if suffix else candidate
+    counter = 2
+    while candidate in used:
+        candidate = f"{stem}-{counter}{suffix}"
+        counter += 1
+    used.add(candidate)
+    return candidate
+
+
+def _printer_zip_root() -> Path:
+    """Return the dedicated staging root without doing event-loop I/O."""
+
+    return settings.archive_dir / "temp" / "printer-file-downloads"
+
+
+def _ensure_printer_zip_root() -> Path:
+    """Create and return the staging root on the persistent data volume."""
+
+    root = _printer_zip_root()
+    root.mkdir(parents=True, exist_ok=True)
+    return root
+
+
+def _prune_stale_bundles(root: Path) -> None:
+    """Remove abandoned bundles after token expiry, without touching archives."""
+
+    cutoff = time.time() - _STALE_BUNDLE_SECONDS
+    if not root.exists():
+        return
+    for child in root.iterdir():
+        try:
+            if child.is_dir() and child.stat().st_mtime < cutoff:
+                shutil.rmtree(child, ignore_errors=True)
+            elif child.is_file() and child.name.startswith("job-") and child.stat().st_mtime < cutoff:
+                child.unlink(missing_ok=True)
+        except OSError:
+            continue
+
+
+async def prune_stale_printer_file_bundles() -> None:
+    """Prune abandoned printer ZIPs without blocking the event loop."""
+
+    root = await asyncio.to_thread(_ensure_printer_zip_root)
+    await asyncio.to_thread(_prune_stale_bundles, root)
+
+
+async def _printer_download_cleanup_loop() -> None:
+    while True:
+        try:
+            await asyncio.sleep(_CLEANUP_INTERVAL_SECONDS)
+            await prune_stale_printer_file_bundles()
+        except asyncio.CancelledError:
+            break
+        except Exception:
+            logger.exception("Periodic printer-download cleanup failed")
+
+
+def start_printer_download_cleanup() -> None:
+    global _cleanup_task
+    if _cleanup_task is None:
+        _cleanup_task = spawn_background_task(_printer_download_cleanup_loop(), name="printer-download-cleanup")
+
+
+async def stop_printer_download_cleanup() -> None:
+    """Stop cleanup and cancel every in-process preparation before shutdown."""
+
+    global _cleanup_task
+    tasks: list[asyncio.Task] = []
+    cleanup_task = _cleanup_task
+    _cleanup_task = None
+    if cleanup_task is not None:
+        cleanup_task.cancel()
+        tasks.append(cleanup_task)
+
+    # Jobs can be inside an FTP worker thread. Publish the same cooperative
+    # cancellation marker used by the DELETE endpoint before cancelling the
+    # asyncio wrapper, then await every wrapper so no executor work is left
+    # behind when the application event loop closes.
+    for job_id, task in list(_LOCAL_JOB_TASKS.items()):
+        if not task.done():
+            await asyncio.to_thread(_job_cancel_path(job_id).touch)
+            task.cancel()
+        tasks.append(task)
+    if tasks:
+        await asyncio.gather(*tasks, return_exceptions=True)
+    _LOCAL_JOB_TASKS.clear()
+
+
+def printer_files_zip_path(printer_id: int, token: str) -> Path | None:
+    """Resolve the staged ZIP for a resource-bound browser token."""
+
+    bundle_key = f"{printer_id}-{token}"
+    if not _BUNDLE_KEY_RE.fullmatch(bundle_key):
+        return None
+    return _printer_zip_root() / bundle_key / "printer-files.zip"
+
+
+def bind_printer_files_zip_to_token(
+    result: PrinterFilesZipResult,
+    printer_id: int,
+    token: str,
+) -> PrinterFilesZipResult:
+    """Move a prepared bundle to the path derived from its persisted token."""
+
+    target = printer_files_zip_path(printer_id, token)
+    if target is None:
+        raise ValueError("Invalid printer ZIP token")
+    result.path.parent.rename(target.parent)
+    return replace(result, path=target)
+
+
+def _check_initial_space(root: Path, sizes: dict[str, int]) -> None:
+    # These sizes are client-reported hints used only for an early rejection,
+    # so this is a courtesy, not the bound. The real one is enforced per write
+    # and per FTP callback below, against actual bytes and the live free space,
+    # which is the only thing that can hold when several preparations run at
+    # once -- and they do: nothing serializes them. Two concurrent jobs that
+    # both pass here stop independently at the reserve, and the one that gets
+    # there second fails with a message saying so.
+    expected_total = sum(sizes.values())
+    if expected_total > MAX_PRINTER_ZIP_BYTES:
+        raise PrinterFilesZipTooLargeError(
+            f"Selected files total {expected_total} bytes; the limit is {MAX_PRINTER_ZIP_BYTES} bytes"
+        )
+
+    largest_file = max(sizes.values(), default=0)
+    # In the worst case the ZIP is as large as the inputs while the largest
+    # source is still staged beside it. Keep a reserve for the database/logs.
+    required = expected_total + largest_file + PRINTER_ZIP_FREE_SPACE_RESERVE
+    free = shutil.disk_usage(root).free
+    if free < required:
+        raise PrinterFilesZipInsufficientSpaceError(
+            f"The app data volume needs {required} bytes free to stage this selection; {free} bytes are available"
+        )
+
+
+async def build_printer_files_zip(
+    printer,
+    paths: list[str],
+    sizes: dict[str, int],
+    *,
+    bundle_key: str | None = None,
+    preserve_paths: bool = True,
+    allow_empty: bool = False,
+    cancel_signal: _FileCancelSignal | None = None,
+    progress_callback: Callable[[int, int], Awaitable[None]] | None = None,
+) -> PrinterFilesZipResult:
+    """Download printer files one at a time into a disk-backed ZIP.
+
+    The previous implementation held every source file and the final ZIP in
+    memory. Continuous `/ipcam` chunks are commonly ~250 MB each, so selecting
+    only a few could exhaust both server and browser memory.
+    """
+
+    root = await asyncio.to_thread(_ensure_printer_zip_root)
+    await asyncio.to_thread(_prune_stale_bundles, root)
+    await asyncio.to_thread(_check_initial_space, root, sizes)
+    bundle_dir: Path | None = None
+    try:
+        if bundle_key is None:
+            bundle_dir = Path(await asyncio.to_thread(tempfile.mkdtemp, prefix="bundle-", dir=root))
+        else:
+            if not _BUNDLE_KEY_RE.fullmatch(bundle_key):
+                raise ValueError("Invalid printer ZIP bundle key")
+            bundle_dir = root / bundle_key
+            await asyncio.to_thread(bundle_dir.mkdir, mode=0o700)
+        zip_path = bundle_dir / "printer-files.zip"
+        successful = 0
+        total_bytes = 0
+        failed_paths: list[str] = []
+        used_names: set[str] = set()
+
+        archive = await asyncio.to_thread(zipfile.ZipFile, zip_path, "w", allowZip64=True)
+        try:
+            for index, remote_path in enumerate(paths):
+                if cancel_signal is not None and cancel_signal.is_set():
+                    raise asyncio.CancelledError
+                if not isinstance(remote_path, str) or not remote_path.startswith("/") or "\x00" in remote_path:
+                    logger.warning("Skipping invalid printer file path: %r", remote_path)
+                    failed_paths.append(remote_path)
+                    continue
+                staged_path = bundle_dir / f"download-{index}"
+                try:
+                    expected_size = sizes.get(remote_path)
+                    if expected_size is not None:
+                        free = (await asyncio.to_thread(shutil.disk_usage, root)).free
+                        if free < expected_size + PRINTER_ZIP_FREE_SPACE_RESERVE:
+                            raise PrinterFilesZipInsufficientSpaceError(
+                                "The app data volume lacks space for the next selected file"
+                            )
+                    downloaded = await download_file_async(
+                        printer.ip_address,
+                        printer.access_code,
+                        remote_path,
+                        staged_path,
+                        timeout=600,
+                        socket_timeout=60,
+                        printer_model=printer.model,
+                        expected_size=expected_size,
+                        max_bytes=MAX_PRINTER_ZIP_BYTES - total_bytes,
+                        cancel_event=cancel_signal,
+                        min_free_bytes=PRINTER_ZIP_FREE_SPACE_RESERVE,
+                        # Outside the per-printer download gate (#2957), in both
+                        # directions. A selection of ~250 MB /ipcam chunks holds
+                        # the printer for as long as it legitimately takes, and
+                        # nothing else should be made to wait that out; equally,
+                        # each file here must not stall behind a thumbnail.
+                        serialize=False,
+                    )
+                    if not downloaded:
+                        failed_paths.append(remote_path)
+                        continue
+                    # Deliberately no second size comparison here. The transfer
+                    # was already checked against the printer's own SIZE, which
+                    # download_to_file treats as the authority precisely because
+                    # it beats a hint the browser round-tripped; re-judging the
+                    # result against that hint would overrule the better number
+                    # with the worse one. The hint goes stale in exactly the case
+                    # this feature exists for -- an /ipcam chunk or a timelapse
+                    # still being written when the listing was taken -- and a
+                    # complete file would then be dropped as "truncated".
+                    file_size = (await asyncio.to_thread(staged_path.stat)).st_size
+                    if total_bytes + file_size > MAX_PRINTER_ZIP_BYTES:
+                        raise PrinterFilesZipTooLargeError(
+                            f"Downloaded files exceed the {MAX_PRINTER_ZIP_BYTES}-byte limit"
+                        )
+                    free = (await asyncio.to_thread(shutil.disk_usage, root)).free
+                    if free < file_size + PRINTER_ZIP_FREE_SPACE_RESERVE:
+                        raise PrinterFilesZipInsufficientSpaceError(
+                            "The app data volume ran out of safe staging space while building the ZIP"
+                        )
+                    compression = (
+                        zipfile.ZIP_STORED if remote_path.lower().endswith(VIDEO_SUFFIXES) else zipfile.ZIP_DEFLATED
+                    )
+                    arc_source = remote_path if preserve_paths else PurePosixPath(remote_path).name
+                    await asyncio.to_thread(
+                        archive.write,
+                        staged_path,
+                        _zip_arcname(arc_source, used_names),
+                        compress_type=compression,
+                    )
+                    successful += 1
+                    total_bytes += file_size
+                except DownloadLimitExceeded as exc:
+                    raise PrinterFilesZipTooLargeError(
+                        f"Downloaded files exceed the {MAX_PRINTER_ZIP_BYTES}-byte limit"
+                    ) from exc
+                except DownloadInsufficientSpace as exc:
+                    raise PrinterFilesZipInsufficientSpaceError(
+                        "The app data volume ran out of safe staging space during transfer"
+                    ) from exc
+                except DownloadCancelled as exc:
+                    raise asyncio.CancelledError from exc
+                except (PrinterFilesZipTooLargeError, PrinterFilesZipInsufficientSpaceError):
+                    raise
+                except Exception as exc:
+                    logger.warning("Failed to add %s to printer ZIP: %s", remote_path, exc)
+                    failed_paths.append(remote_path)
+                finally:
+                    await asyncio.to_thread(staged_path.unlink, missing_ok=True)
+                    if progress_callback is not None:
+                        await progress_callback(successful, len(failed_paths))
+        finally:
+            await asyncio.shield(asyncio.to_thread(archive.close))
+    except BaseException:
+        if bundle_dir is not None:
+            await asyncio.shield(asyncio.to_thread(shutil.rmtree, bundle_dir, ignore_errors=True))
+        raise
+
+    if successful == 0 and not allow_empty:
+        await asyncio.to_thread(shutil.rmtree, bundle_dir, ignore_errors=True)
+        raise FileNotFoundError("No files could be downloaded")
+    return PrinterFilesZipResult(
+        path=zip_path,
+        requested=len(paths),
+        successful=successful,
+        failed_paths=tuple(failed_paths),
+        total_bytes=total_bytes,
+    )
+
+
+def printer_file_path(printer_id: int, token: str) -> Path | None:
+    """Resolve a prepared native single-file download."""
+
+    bundle_key = f"{printer_id}-{token}"
+    if not _BUNDLE_KEY_RE.fullmatch(bundle_key):
+        return None
+    return _printer_zip_root() / bundle_key / "printer-file"
+
+
+def bind_printer_file_to_token(result: PrinterFilesZipResult, printer_id: int, token: str) -> PrinterFilesZipResult:
+    target = printer_file_path(printer_id, token)
+    if target is None:
+        raise ValueError("Invalid printer file token")
+    result.path.parent.rename(target.parent)
+    return replace(result, path=target)
+
+
+async def build_printer_file(
+    printer,
+    remote_path: str,
+    expected_size: int | None,
+    *,
+    bundle_key: str,
+    cancel_signal: _FileCancelSignal | None = None,
+) -> PrinterFilesZipResult:
+    """Stage one printer file on disk for a browser-native download.
+
+    Also the read path for the 3MF preview in the file browser, which is why
+    nothing here waits on a shared lock: a preview must not queue behind
+    somebody else's ten-gigabyte selection for as long as that takes.
+    """
+
+    if not remote_path.startswith("/") or "\x00" in remote_path:
+        raise FileNotFoundError("Invalid printer file path")
+    root = await asyncio.to_thread(_ensure_printer_zip_root)
+    size_hints = {remote_path: expected_size} if expected_size is not None else {}
+    await asyncio.to_thread(_check_initial_space, root, size_hints)
+    bundle_dir = root / bundle_key
+    try:
+        await asyncio.to_thread(bundle_dir.mkdir, mode=0o700)
+        local_path = bundle_dir / "printer-file"
+        downloaded = await download_file_async(
+            printer.ip_address,
+            printer.access_code,
+            remote_path,
+            local_path,
+            timeout=600,
+            socket_timeout=60,
+            printer_model=printer.model,
+            expected_size=expected_size,
+            max_bytes=MAX_PRINTER_ZIP_BYTES,
+            cancel_event=cancel_signal,
+            min_free_bytes=PRINTER_ZIP_FREE_SPACE_RESERVE,
+            # The lock-free promise in this function's docstring, kept: a preview
+            # must not queue behind somebody else's selection (#2957).
+            serialize=False,
+        )
+        if not downloaded:
+            raise FileNotFoundError("The selected printer file could not be downloaded")
+        file_size = (await asyncio.to_thread(local_path.stat)).st_size
+        return PrinterFilesZipResult(
+            path=local_path,
+            requested=1,
+            successful=1,
+            failed_paths=(),
+            total_bytes=file_size,
+        )
+    except DownloadLimitExceeded as exc:
+        await asyncio.shield(asyncio.to_thread(shutil.rmtree, bundle_dir, ignore_errors=True))
+        raise PrinterFilesZipTooLargeError(f"Downloaded file exceeds the {MAX_PRINTER_ZIP_BYTES}-byte limit") from exc
+    except DownloadInsufficientSpace as exc:
+        await asyncio.shield(asyncio.to_thread(shutil.rmtree, bundle_dir, ignore_errors=True))
+        raise PrinterFilesZipInsufficientSpaceError(
+            "The app data volume ran out of safe staging space during transfer"
+        ) from exc
+    except DownloadCancelled as exc:
+        await asyncio.shield(asyncio.to_thread(shutil.rmtree, bundle_dir, ignore_errors=True))
+        raise asyncio.CancelledError from exc
+    except BaseException:
+        await asyncio.shield(asyncio.to_thread(shutil.rmtree, bundle_dir, ignore_errors=True))
+        raise
+
+
+async def _run_printer_files_job(
+    printer,
+    job_id: str,
+    paths: list[str],
+    sizes: dict[str, int],
+    filename: str,
+    as_zip: bool,
+) -> None:
+    from backend.app.core.auth import create_slicer_download_token
+
+    cancel_signal = _FileCancelSignal(_job_cancel_path(job_id))
+    status = PrinterFilesJobStatus(job_id, printer.id, "preparing", len(paths), filename=filename)
+    await asyncio.to_thread(_write_job_status, status)
+
+    async def report_progress(successful: int, failed: int) -> None:
+        await asyncio.to_thread(
+            _write_job_status,
+            PrinterFilesJobStatus(
+                job_id,
+                printer.id,
+                "preparing",
+                len(paths),
+                successful=successful,
+                failed=failed,
+                filename=filename,
+            ),
+        )
+
+    try:
+        async with asyncio.timeout(MAX_PRINTER_ZIP_PREPARE_SECONDS):
+            if as_zip:
+                result = await build_printer_files_zip(
+                    printer,
+                    paths,
+                    sizes,
+                    bundle_key=f"job-{job_id}",
+                    cancel_signal=cancel_signal,
+                    progress_callback=report_progress,
+                )
+            else:
+                result = await build_printer_file(
+                    printer,
+                    paths[0],
+                    sizes.get(paths[0]),
+                    bundle_key=f"job-{job_id}",
+                    cancel_signal=cancel_signal,
+                )
+        if cancel_signal.is_set():
+            await asyncio.to_thread(remove_printer_files_zip, result.path)
+            raise asyncio.CancelledError
+        token = await create_slicer_download_token("printer-files", printer.id)
+        if as_zip:
+            result = await asyncio.to_thread(bind_printer_files_zip_to_token, result, printer.id, token)
+        else:
+            result = await asyncio.to_thread(bind_printer_file_to_token, result, printer.id, token)
+        await asyncio.to_thread(
+            _write_job_status,
+            PrinterFilesJobStatus(
+                job_id,
+                printer.id,
+                "ready",
+                len(paths),
+                successful=result.successful,
+                failed=len(result.failed_paths),
+                token=token,
+                filename=filename,
+            ),
+        )
+    except asyncio.CancelledError:
+        await asyncio.shield(
+            asyncio.to_thread(
+                _write_job_status,
+                PrinterFilesJobStatus(job_id, printer.id, "cancelled", len(paths), filename=filename),
+            )
+        )
+    except PrinterFilesZipTooLargeError as exc:
+        await asyncio.to_thread(
+            _write_job_status,
+            PrinterFilesJobStatus(job_id, printer.id, "failed", len(paths), filename=filename, message=str(exc)),
+        )
+    except PrinterFilesZipInsufficientSpaceError as exc:
+        await asyncio.to_thread(
+            _write_job_status,
+            PrinterFilesJobStatus(job_id, printer.id, "failed", len(paths), filename=filename, message=str(exc)),
+        )
+    except TimeoutError:
+        await asyncio.to_thread(
+            _write_job_status,
+            PrinterFilesJobStatus(
+                job_id,
+                printer.id,
+                "failed",
+                len(paths),
+                filename=filename,
+                message="Printer download preparation exceeded the 30-minute limit",
+            ),
+        )
+    except FileNotFoundError as exc:
+        await asyncio.to_thread(
+            _write_job_status,
+            PrinterFilesJobStatus(job_id, printer.id, "failed", len(paths), filename=filename, message=str(exc)),
+        )
+    except Exception:
+        logger.exception("Printer download job %s failed", job_id)
+        await asyncio.to_thread(
+            _write_job_status,
+            PrinterFilesJobStatus(
+                job_id,
+                printer.id,
+                "failed",
+                len(paths),
+                filename=filename,
+                message="Printer download preparation failed",
+            ),
+        )
+    finally:
+        await asyncio.to_thread(_job_cancel_path(job_id).unlink, missing_ok=True)
+
+
+async def start_printer_files_job(
+    printer,
+    paths: list[str],
+    sizes: dict[str, int],
+    filename: str,
+    *,
+    as_zip: bool,
+) -> PrinterFilesJobStatus:
+    """Start a bounded background preparation and return immediately."""
+
+    if not paths:
+        raise ValueError("No files specified")
+    root = await asyncio.to_thread(_ensure_printer_zip_root)
+    await asyncio.to_thread(_prune_stale_bundles, root)
+    await asyncio.to_thread(_check_initial_space, root, sizes)
+    job_id = secrets.token_urlsafe(24)
+    status = PrinterFilesJobStatus(job_id, printer.id, "queued", len(paths), filename=filename)
+    await asyncio.to_thread(_write_job_status, status)
+    task = spawn_background_task(
+        _run_printer_files_job(printer, job_id, paths, sizes, filename, as_zip),
+        name=f"printer-download-{printer.id}-{job_id}",
+    )
+    _LOCAL_JOB_TASKS[job_id] = task
+    task.add_done_callback(lambda _task: _LOCAL_JOB_TASKS.pop(job_id, None))
+    return status
+
+
+async def get_printer_files_job(job_id: str, printer_id: int) -> PrinterFilesJobStatus | None:
+    status = await asyncio.to_thread(_read_job_status, job_id)
+    if status is None or status.printer_id != printer_id:
+        return None
+    return status
+
+
+async def cancel_printer_files_job(job_id: str, printer_id: int) -> bool:
+    status = await get_printer_files_job(job_id, printer_id)
+    if status is None:
+        return False
+    await asyncio.to_thread(_job_cancel_path(job_id).touch)
+    task = _LOCAL_JOB_TASKS.get(job_id)
+    if task is not None and not task.done():
+        task.cancel()
+    if status.state == "ready" and status.token:
+        zip_path = printer_files_zip_path(printer_id, status.token)
+        prepared = (
+            zip_path
+            if zip_path is not None and await asyncio.to_thread(zip_path.is_file)
+            else printer_file_path(printer_id, status.token)
+        )
+        if prepared is not None:
+            await asyncio.to_thread(remove_printer_files_zip, prepared)
+        await asyncio.to_thread(
+            _write_job_status,
+            replace(status, state="cancelled", token=None),
+        )
+    return True
+
+
+def remove_printer_files_zip(zip_path: Path) -> None:
+    """Remove a completed download bundle after FileResponse finishes."""
+
+    shutil.rmtree(zip_path.parent, ignore_errors=True)

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

@@ -11,6 +11,18 @@ 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.
+
+``unresolved_filament_slots`` covers a quieter failure found while
+investigating #2977: a filament profile whose name the sidecar's bundle
+cannot resolve is not rejected. The CLI inherits nothing, falls back to its
+compiled-in defaults for every field, and returns a perfectly well-formed
+success. Measured against a 02.08.02.61 sidecar, a profile named for a preset
+that does not exist slices as ``filament_type: ["PLA"]`` at
+``nozzle_temperature: ["200"]`` with ``filament_ids: [""]`` and
+``filament_vendor: ["(Undefined)"]`` — so a PETG preset that fails to resolve
+prints at PLA temperatures. Unlike the missing start G-code this does not make
+the file unprintable, only wrong, so it is reported as a warning and the slice
+is kept.
 """
 
 from __future__ import annotations
@@ -93,3 +105,73 @@ def missing_start_gcode_message(printer_preset_name: str) -> str:
         "companion profile that holds the real start G-code for most Bambu printers. "
         "Update the sidecar and slice again."
     )
+
+
+# What the CLI writes into a filament slot it could not resolve. Bambu Studio
+# uses this literal for a filament whose vendor is unknown, and it is the one
+# field that separates "nothing inherited" from a legitimately vendor-less
+# profile: a resolved preset always carries a real ``filament_ids`` entry
+# (``GFL96`` for Generic PLA Silk, ``GFG99`` for Generic PETG), while an
+# unresolved one carries the empty string.
+_UNDEFINED_VENDOR = "(Undefined)"
+
+
+def unresolved_filament_slots(content: bytes, *, export_3mf: bool) -> list[int]:
+    """1-indexed filament slots the slicer could not resolve a preset for.
+
+    Empty whenever the question cannot be settled — a raw-G-code response (the
+    per-slot config only exists in the 3MF), an unreadable archive, a missing
+    or malformed config. Same principle as ``start_gcode_is_missing``: a check
+    that recognises one specific defect must not report anything it has not
+    actually seen.
+
+    Both signals are required together. ``filament_vendor`` alone would flag a
+    hand-written profile that simply never named a vendor, and ``filament_ids``
+    alone would flag a user's own cloud preset, which legitimately carries no
+    bundled filament id. A slot that has neither inherited a vendor nor been
+    given an id is one where the ``inherits:`` target did not exist.
+    """
+    if not content or not export_3mf:
+        return []
+
+    try:
+        with zipfile.ZipFile(io.BytesIO(content)) as archive:
+            raw = archive.read(_PROJECT_SETTINGS)
+        settings = json.loads(raw)
+    except (KeyError, OSError, zipfile.BadZipFile, UnicodeDecodeError, json.JSONDecodeError) as exc:
+        logger.debug("Filament resolution check skipped: cannot read %s (%s)", _PROJECT_SETTINGS, exc)
+        return []
+
+    if not isinstance(settings, dict):
+        return []
+    vendors = settings.get("filament_vendor")
+    ids = settings.get("filament_ids")
+    if not isinstance(vendors, list) or not isinstance(ids, list):
+        logger.debug("Filament resolution check skipped: no per-slot vendor/id arrays")
+        return []
+
+    unresolved: list[int] = []
+    for slot in range(min(len(vendors), len(ids))):
+        if _as_text(vendors[slot]).strip() == _UNDEFINED_VENDOR and not _as_text(ids[slot]).strip():
+            unresolved.append(slot + 1)
+    return unresolved
+
+
+def unresolved_filament_message(slots: list[int], preset_names: list[str]) -> str:
+    """The warning logged for slots whose filament preset did not resolve.
+
+    Names the presets by the slot they were picked for, because the user picked
+    them per slot and that is the only handle they have on which dropdown to
+    change.
+    """
+    parts: list[str] = []
+    for slot in slots:
+        name = preset_names[slot - 1] if slot - 1 < len(preset_names) else ""
+        parts.append(f"slot {slot} ({name})" if name else f"slot {slot}")
+    return (
+        f"The slicer could not resolve the filament preset for {', '.join(parts)}, so those slots "
+        "were sliced with its built-in defaults (PLA, 200 C) instead of the preset's own settings. "
+        "The file was kept, but check the temperatures before printing. This usually means the "
+        "slicer sidecar's bundled profiles do not contain the preset that was picked - updating "
+        "the sidecar image, or picking a preset from its own bundled list, resolves it."
+    )

+ 63 - 12
backend/app/services/slicer_filament_resolver.py

@@ -42,14 +42,34 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from backend.app.models.user import User
 from backend.app.utils.filament_ids import (
     GENERIC_FILAMENT_IDS,
-    MATERIAL_TEMPS,
     filament_id_to_setting_id,
     normalize_slicer_filament,
 )
+from backend.app.utils.filament_types import is_material_name
 
 logger = logging.getLogger(__name__)
 
-_KNOWN_MATERIALS = set(MATERIAL_TEMPS.keys()) | set(GENERIC_FILAMENT_IDS.keys())
+
+def _preset_filament_type(raw: object) -> str | None:
+    """Read a slicer preset's ``filament_type`` field.
+
+    Bambu Studio and OrcaSlicer both store it as a one-element array
+    (``["PLA"]``); some hand-written and older profiles store a bare string.
+    ``orca_profiles._extract_filament_fields`` accepts both and this has to
+    agree with it, since that is what fills ``LocalPreset.filament_type``.
+
+    The value is still run through ``printer_filament_type`` by the caller.
+    That is a no-op for every type the app knows -- ``TestTheMaterialsBambuddyOffers``
+    pins exactly that -- and a pass-through for a type it does not, so nothing
+    the slicer says is discarded. It only bites on a hand-edited profile whose
+    ``filament_type`` is a product line, which is the case this whole module
+    exists to keep out of an AMS slot.
+    """
+    if isinstance(raw, list):
+        raw = raw[0] if raw else None
+    if isinstance(raw, str) and raw.strip():
+        return raw.strip()
+    return None
 
 
 async def resolve_slicer_filament(
@@ -59,7 +79,7 @@ async def resolve_slicer_filament(
     slicer_filament: str | None,
     slicer_filament_name: str | None,
     material: str | None,
-) -> tuple[str, str, str | None]:
+) -> tuple[str, str, str | None, str | None]:
     """Resolve a spool's slicer-preset reference to printer-side ids.
 
     ``slicer_filament``: the spool's stored reference (e.g. ``"GFA01"``,
@@ -74,18 +94,30 @@ async def resolve_slicer_filament(
     ``material``: spool material string for the local-preset fallback
     branch when the LocalPreset's setting JSON doesn't carry a filament_id.
 
-    Returns ``(tray_info_idx, setting_id, sub_brand_override)`` — all empty
-    when nothing resolved. ``sub_brand_override`` is non-None when a more
-    specific brand label is available (cloud detail name or local preset
+    Returns ``(tray_info_idx, setting_id, sub_brand_override, type_override)``
+    — all empty when nothing resolved. ``sub_brand_override`` is non-None when
+    a more specific brand label is available (cloud detail name or local preset
     name); ``None`` means the caller should use its own default.
+
+    ``type_override`` is the preset's own ``filament_type`` when the preset
+    carries one — the slicer's answer to what the material is, rather than one
+    parsed out of the spool's material column. It is what the caller should
+    write into ``tray_type``. ``None`` means no preset said, and the caller
+    falls back to reducing the spool's material (``printer_filament_type``).
+    Raised in the #2902 thread by @doncaruana: a preset has to be chosen from
+    a list the slicer defines, so its type needs no interpreting. It cannot be
+    the only source, though — ``slicer_filament`` is nullable on a spool while
+    ``material`` is required, and the spool this issue was reported for had no
+    preset at all.
     """
     sf = (slicer_filament or "").strip()
     if not sf:
-        return ("", "", None)
+        return ("", "", None, None)
 
     tray_info_idx = ""
     setting_id = ""
     sub_brand_override: str | None = None
+    type_override: str | None = None
 
     base_sf = sf.split("_")[0] if "_" in sf else sf
 
@@ -106,6 +138,15 @@ async def resolve_slicer_filament(
             if cloud is not None and cloud.is_authenticated:
                 try:
                     detail = await cloud.get_setting_detail(base_sf)
+                    # The preset's own type, straight from the slicer's own
+                    # profile -- no parsing of a product name (#2902). The
+                    # preset JSON is nested under ``setting``; some responses
+                    # carry it at the top level instead, the same shape spread
+                    # ``preset_resolver`` documents.
+                    cloud_setting = detail.get("setting")
+                    type_override = _preset_filament_type(
+                        (cloud_setting if isinstance(cloud_setting, dict) else detail).get("filament_type")
+                    )
                     if detail.get("filament_id"):
                         tray_info_idx = detail["filament_id"]
                         cloud_name = detail.get("name", "")
@@ -136,6 +177,10 @@ async def resolve_slicer_filament(
             lp_result = await db.execute(select(LP).where(LP.id == local_id, LP.preset_type == "filament"))
             lp = lp_result.scalar_one_or_none()
             if lp:
+                # The slicer's own answer, extracted from the profile at import
+                # time by ``orca_profiles``. Preferred over anything parsed out
+                # of the spool's material column (#2902).
+                type_override = _preset_filament_type(lp.filament_type)
                 # Local preset's setting JSON carries the printer-recognized
                 # filament_id (e.g. "P4d64437") — use that directly so the
                 # slicer can resolve the specific preset. Falls through to
@@ -153,6 +198,12 @@ async def resolve_slicer_filament(
                     tray_info_idx = lp_filament_id
                     setting_id = filament_id_to_setting_id(lp_filament_id)
                 else:
+                    # Deliberately not widened to cover product-line materials
+                    # ("PLA+", "HTPLA") the way the callers' own fallbacks were
+                    # (#2902). Returning an id here rather than "" would skip
+                    # the caller's whole no-id block, and with it the slot-reuse
+                    # branch that keeps a printer's calibrated preset -- so the
+                    # widening belongs there, after reuse has had its turn.
                     mat = (material or lp.filament_type or "").upper().strip()
                     tray_info_idx = (
                         GENERIC_FILAMENT_IDS.get(mat) or GENERIC_FILAMENT_IDS.get(mat.split("-")[0].split(" ")[0]) or ""
@@ -181,7 +232,9 @@ async def resolve_slicer_filament(
     # material fallback can rescue the slot:
     #   1. Literal material names ("PLA", "PETG-CF") that pass through
     #      normalize_slicer_filament unchanged when the spool's slicer_filament
-    #      is free-text rather than a real preset ID.
+    #      is free-text rather than a real preset ID. Product lines ("PLA+",
+    #      "HTPLA") count as material names too -- see is_material_name, which
+    #      is shared with the slot-reuse check that must agree with this.
     #   2. PFUS-prefix cloud setting_ids — valid as setting_id but rejected
     #      by the slicer as tray_info_idx (the printer's calibration table
     #      indexes by filament_id, and a PFUS isn't one). This normally gets
@@ -195,9 +248,7 @@ async def resolve_slicer_filament(
     # Valid tray_info_idx values: "GF" + letter + digits (Bambu official) or
     # "P" followed by hex (user/local presets, NOT "PFUS" or "PFCN").
     if tray_info_idx and (
-        tray_info_idx.upper() in _KNOWN_MATERIALS
-        or tray_info_idx.startswith("PFUS")
-        or tray_info_idx.startswith("PFCN")
+        is_material_name(tray_info_idx) or tray_info_idx.startswith("PFUS") or tray_info_idx.startswith("PFCN")
     ):
         tray_info_idx = ""
         # Preserve setting_id when it's still a valid slicer reference
@@ -215,4 +266,4 @@ async def resolve_slicer_filament(
         ):
             setting_id = ""
 
-    return (tray_info_idx, setting_id, sub_brand_override)
+    return (tray_info_idx, setting_id, sub_brand_override, type_override)

+ 189 - 0
backend/app/services/slot_kprofile.py

@@ -0,0 +1,189 @@
+"""Find the stored K-profile for an AMS slot on a particular nozzle.
+
+K-profiles are per-nozzle: Bambuddy already keeps one row per
+``(spool, printer, extruder)`` in ``spool_k_profile`` (and the Spoolman mirror
+in ``spoolman_k_profile``), each with its own ``cali_idx`` and K value. On the
+maintainer's H2C, one black PLA reads 0.018 on the left hotend and 0.020 on the
+right, stored as calibration indices 16 and 15.
+
+A tray, by contrast, holds exactly **one** ``cali_idx``. So whenever a slot's
+nozzle changes — which on a Filament Track Switch machine happens every time an
+AMS is moved between the switch's two inlets — the stored counterpart for the
+new nozzle has to be looked up and re-selected. This module is that lookup.
+"""
+
+from dataclasses import dataclass
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.spool import Spool
+from backend.app.models.spool_assignment import SpoolAssignment
+from backend.app.models.spool_k_profile import SpoolKProfile
+from backend.app.models.spoolman_k_profile import SpoolmanKProfile
+from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+from backend.app.services.inventory_mode import spoolman_owns_assignments
+from backend.app.services.spool_filament_preset import resolve_spool_preset, resolve_spoolman_preset
+
+
+@dataclass(frozen=True)
+class SlotKProfile:
+    """A stored calibration profile, flattened across the two inventory backends."""
+
+    cali_idx: int | None
+    k_value: float | None
+    name: str | None
+    extruder: int
+    # The preset the profile was calibrated under. ``extrusion_cali_sel`` must
+    # carry this rather than the tray's RFID value, or the firmware mislinks it.
+    filament_id: str | None
+
+
+def _flow_applies(stored_flow: str | None, fitted_flow: str | None) -> bool:
+    """``SlotNozzle.flow_matches`` for callers that hold only the two strings."""
+    from backend.app.services.slot_nozzle import SlotNozzle
+
+    return SlotNozzle(extruder=None, diameter="", flow=fitted_flow).flow_matches(stored_flow)
+
+
+async def find_slot_kprofile_for_extruder(
+    db: AsyncSession,
+    printer_id: int,
+    ams_id: int,
+    tray_id: int,
+    extruder: int,
+    nozzle_diameter: str,
+    printer_model: str | None = None,
+    flow: str | None = None,
+) -> SlotKProfile | None:
+    """Stored profile for whatever is in this slot, calibrated for ``extruder``.
+
+    Returns None when the slot holds no known spool, or when that spool has no
+    profile for this nozzle — an operator who calibrated only one side should
+    keep the binding they set by hand rather than have it swapped for a guess.
+
+    Only the table the current inventory mode uses is consulted. Before #2812
+    the inactive one was emptied on every mode toggle, so reading the built-in
+    table first and stopping on a hit was safe -- there could be nothing in it
+    to stop on. Nothing is emptied now, and a leftover built-in row would
+    otherwise shadow the Spoolman assignment for the slot, returning that
+    spool's profile or, on the deliberate stop below, no profile at all. That
+    is the symptom #1556 reported from the other direction.
+    """
+    spoolman_mode = await spoolman_owns_assignments(db)
+
+    assignment = (
+        None
+        if spoolman_mode
+        else (
+            await db.execute(
+                select(SpoolAssignment).where(
+                    SpoolAssignment.printer_id == printer_id,
+                    SpoolAssignment.ams_id == ams_id,
+                    SpoolAssignment.tray_id == tray_id,
+                )
+            )
+        ).scalar_one_or_none()
+    )
+
+    if assignment is not None:
+        profile = (
+            (
+                await db.execute(
+                    select(SpoolKProfile).where(
+                        SpoolKProfile.spool_id == assignment.spool_id,
+                        SpoolKProfile.printer_id == printer_id,
+                        SpoolKProfile.extruder == extruder,
+                        SpoolKProfile.nozzle_diameter == nozzle_diameter,
+                    )
+                )
+            )
+            .scalars()
+            .all()
+        )
+        # Flow is filtered here rather than in SQL: a stored NULL matches any
+        # fitted nozzle (see SlotNozzle.flow_matches), which is not an equality
+        # test and would need an OR IS NULL that reads worse than this.
+        profile = next((p for p in profile if _flow_applies(p.nozzle_type, flow)), None)
+        if profile is not None:
+            spool = (await db.execute(select(Spool).where(Spool.id == assignment.spool_id))).scalar_one_or_none()
+            # The preset this profile was calibrated under, through the
+            # per-printer-model cascade: a spool can carry a different preset
+            # per model, and extrusion_cali_sel has to name the one the printer
+            # will actually see in the slot. Falls back to the spool's own
+            # value when the caller cannot say which model this is.
+            filament_id = spool.slicer_filament if spool else None
+            if spool is not None and printer_model:
+                filament_id, _ = await resolve_spool_preset(
+                    db,
+                    spool_id=spool.id,
+                    printer_model=printer_model,
+                    nozzle_diameter=nozzle_diameter,
+                    fallback_filament=spool.slicer_filament,
+                    fallback_name=spool.slicer_filament_name,
+                )
+            return SlotKProfile(
+                cali_idx=profile.cali_idx,
+                k_value=profile.k_value,
+                name=profile.name,
+                extruder=profile.extruder,
+                filament_id=filament_id,
+            )
+        # A known spool with no profile for this nozzle is a deliberate stop:
+        # falling through to Spoolman would answer for a different spool.
+        return None
+
+    if not spoolman_mode:
+        return None
+
+    sm_assignment = (
+        await db.execute(
+            select(SpoolmanSlotAssignment).where(
+                SpoolmanSlotAssignment.printer_id == printer_id,
+                SpoolmanSlotAssignment.ams_id == ams_id,
+                SpoolmanSlotAssignment.tray_id == tray_id,
+            )
+        )
+    ).scalar_one_or_none()
+    if sm_assignment is None:
+        return None
+
+    sm_profile = (
+        (
+            await db.execute(
+                select(SpoolmanKProfile).where(
+                    SpoolmanKProfile.spoolman_spool_id == sm_assignment.spoolman_spool_id,
+                    SpoolmanKProfile.printer_id == printer_id,
+                    SpoolmanKProfile.extruder == extruder,
+                    SpoolmanKProfile.nozzle_diameter == nozzle_diameter,
+                )
+            )
+        )
+        .scalars()
+        .all()
+    )
+    sm_profile = next((p for p in sm_profile if _flow_applies(p.nozzle_type, flow)), None)
+    if sm_profile is None:
+        return None
+
+    # A Spoolman K row carries no preset of its own, but the spool can still
+    # have a per-model override stored locally -- that is the same table the
+    # Spoolman assign path writes. Without a model to key on there is nothing
+    # to resolve and the caller falls back to the tray's own tray_info_idx.
+    sm_filament_id = None
+    if printer_model:
+        sm_filament_id, _ = await resolve_spoolman_preset(
+            db,
+            spoolman_spool_id=sm_assignment.spoolman_spool_id,
+            printer_model=printer_model,
+            nozzle_diameter=nozzle_diameter,
+            fallback_filament=None,
+            fallback_name=None,
+        )
+    return SlotKProfile(
+        cali_idx=sm_profile.cali_idx,
+        k_value=sm_profile.k_value,
+        name=sm_profile.name,
+        extruder=sm_profile.extruder,
+        filament_id=sm_filament_id,
+    )

+ 179 - 0
backend/app/services/slot_nozzle.py

@@ -0,0 +1,179 @@
+"""Which nozzle does this AMS slot feed, and how wide is it?
+
+Every path that configures a slot needs the same two facts: the extruder the
+slot feeds, and that nozzle's diameter. Both the filament preset and the K
+profile are stored per nozzle diameter, so getting the diameter wrong silently
+selects the wrong preset *and* the wrong K value -- and before this module the
+answer was worked out independently in seven places, each with ``nozzles[0]``
+hard-coded as the diameter for every slot on the machine.
+
+``nozzles[0]`` is correct on a single-nozzle printer and correct on a
+dual-nozzle printer with the same size fitted both sides, which is why it has
+survived. It is wrong the moment someone fits a 0.4 and a 0.2, which is exactly
+the machine this feature exists for.
+
+## Which array index belongs to which extruder
+
+``PrinterState.nozzles`` is filled by two different MQTT parsers that use
+opposite conventions, and this module is where that is resolved once:
+
+* The **H2/X2 path** (``bambu_mqtt`` ~5100) writes ``nozzles[nozzle["id"]]``
+  straight from ``device.nozzle.info``, i.e. indexed by physical nozzle id.
+* The **legacy path** (~5013) writes left -> ``nozzles[0]``, right ->
+  ``nozzles[1]``, which is the reverse of the extruder ids (extruder 0 is the
+  RIGHT hotend).
+
+**MEASURED 2026-08-27 on an H2D with 0.4 high flow LEFT and 0.6 high flow
+RIGHT: ``nozzles[0]`` read 0.6 -- the right hotend, which is extruder 0.** So
+the array is indexed by extruder id, and the H2 convention (physical nozzle id N
+sits on extruder N) is the one that holds.
+
+The legacy branch cannot govern a real dual-nozzle machine anyway: every model
+in ``DUAL_NOZZLE_MODELS`` is H2-series or X2D, all of which report
+``device.nozzle.info``, and ``left_nozzle_diameter`` appears nowhere in any
+captured log or wire trace. On a single-nozzle printer both conventions agree
+that index 0 is the only nozzle.
+
+The distinction is invisible on a machine with matching nozzles, since both
+conventions then return the same string -- which is why it went unnoticed for so
+long, and why this is the single place to change if a future model contradicts
+it.
+"""
+
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass
+
+from backend.app.utils.fts_routing import slot_extruder
+from backend.app.utils.printer_models import is_dual_nozzle_model
+
+logger = logging.getLogger(__name__)
+
+# What a printer that has told us nothing is assumed to have fitted. Matches
+# the default every call site used before this module existed.
+DEFAULT_NOZZLE_DIAMETER = "0.4"
+
+
+@dataclass(frozen=True)
+class SlotNozzle:
+    """The nozzle an AMS slot feeds."""
+
+    # None when the printer has not said which extruder this slot feeds. Callers
+    # that must have a number use ``extruder_or_default``; callers that store a
+    # row keep the None so "unknown" is not written as "the right-hand nozzle".
+    extruder: int | None
+    diameter: str
+    # "HH" (high flow), "HS" (standard), or None when the printer has not said.
+    flow: str | None = None
+
+    @property
+    def extruder_or_default(self) -> int:
+        """0 when unknown -- correct on a single-nozzle machine, a guess on a dual."""
+        return 0 if self.extruder is None else self.extruder
+
+    def flow_matches(self, stored_flow: str | None) -> bool:
+        """Whether a stored K profile's flow type applies to this nozzle.
+
+        Unknown on either side matches anything, and that is the load-bearing
+        case rather than a nicety:
+
+        * Every K profile stored before this existed has NULL here, so a strict
+          comparison would stop applying all of them at once.
+        * An X1C declares no flow on any calibration entry -- measured: all
+          eight come back with ``nozzle_id: ''`` -- so profiles saved from one
+          have nothing truthful to store. Treating "no answer" as "Standard"
+          and then filtering on it would break the moment a high-flow nozzle is
+          fitted to a machine whose table never mentioned flow.
+
+        Once BOTH sides do declare one, they have to agree: a K value measured
+        on a high-flow nozzle is not a fact about a standard one, the same way
+        a 0.6 measurement says nothing about a 0.4.
+        """
+        if not stored_flow or not self.flow:
+            return True
+        return normalise_flow(stored_flow) == self.flow
+
+
+def normalise_flow(raw: str | None) -> str | None:
+    """The flow-type code in a nozzle id or type string, or None.
+
+    Both spellings reduce to the same two letters, which is the whole point:
+    a calibration entry files its nozzle as ``HH00-0.4`` / ``HS00-0.4`` while
+    the fitted nozzle reports its type as ``HH01`` -- measured on an H2D, and
+    the reason this compares two characters rather than four. The trailing
+    digits are a hardware variant the calibration table normalises to ``00``.
+    """
+    text = (raw or "").strip().upper()
+    return text[:2] if text[:2] in ("HH", "HS") else None
+
+
+def nozzle_flow_for_extruder(state, extruder: int | None, model: str | None = None) -> str | None:
+    """The flow type fitted to ``extruder``, or None when the printer is silent.
+
+    Read from the same array as the diameter and indexed the same way. A
+    printer that reports no nozzle type -- an X1C sends none at all -- yields
+    None, which ``flow_matches`` treats as "applies to anything" rather than
+    inventing Standard.
+    """
+    nozzles = getattr(state, "nozzles", None) or []
+    if not nozzles:
+        return None
+
+    index = 0
+    if extruder is not None and extruder > 0 and is_dual_nozzle_model(model):
+        index = extruder
+
+    for candidate in (index, 0):
+        if candidate < len(nozzles):
+            flow = normalise_flow(getattr(nozzles[candidate], "nozzle_type", ""))
+            if flow:
+                return flow
+    return None
+
+
+def nozzle_diameter_for_extruder(state, extruder: int | None, model: str | None = None) -> str:
+    """The diameter fitted to ``extruder``, or the printer's only nozzle.
+
+    Falls back to index 0, and then to 0.4, whenever the printer has not
+    reported the entry -- an absent nozzle must not make this raise, since it is
+    called on every assign.
+    """
+    nozzles = getattr(state, "nozzles", None) or []
+    if not nozzles:
+        return DEFAULT_NOZZLE_DIAMETER
+
+    index = 0
+    if extruder is not None and extruder > 0 and is_dual_nozzle_model(model):
+        # Physical nozzle id N sits on extruder N -- see the module docstring
+        # for why the legacy left/right convention cannot apply here.
+        index = extruder
+
+    for candidate in (index, 0):
+        if candidate < len(nozzles):
+            diameter = (getattr(nozzles[candidate], "nozzle_diameter", "") or "").strip()
+            if diameter:
+                return diameter
+    return DEFAULT_NOZZLE_DIAMETER
+
+
+def resolve_slot_nozzle(state, ams_id: int, tray_id: int, model: str | None = None) -> SlotNozzle:
+    """The extruder an AMS slot feeds and that nozzle's diameter.
+
+    ``state`` is the live ``PrinterState`` (or None when the printer is not
+    connected, which yields the defaults rather than an error).
+    """
+    if state is None:
+        return SlotNozzle(extruder=None, diameter=DEFAULT_NOZZLE_DIAMETER)
+
+    extruder = slot_extruder(
+        ams_id,
+        tray_id,
+        getattr(state, "ams_extruder_map", None),
+        getattr(state, "ams_switch_inlet", None),
+    )
+    return SlotNozzle(
+        extruder=extruder,
+        diameter=nozzle_diameter_for_extruder(state, extruder, model),
+        flow=nozzle_flow_for_extruder(state, extruder, model),
+    )

+ 85 - 42
backend/app/services/spool_assignment_notifications.py

@@ -6,6 +6,7 @@ from backend.app.models.printer import Printer
 from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 from backend.app.services.bambu_mqtt import PrinterState
+from backend.app.services.inventory_mode import spoolman_owns_assignments
 from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import printer_manager
 
@@ -131,52 +132,94 @@ async def notify_missing_spool_assignments_on_print_start(
             printer = await db.get(Printer, printer_id)
             printer_name = printer.name if printer else f"Printer {printer_id}"
 
-            # A tray is "assigned" if it has a row in EITHER table: the legacy
-            # spool_assignment table (internal-inventory mode) or
-            # spoolman_slot_assignments (Spoolman mode — the binding
-            # source-of-truth since #1119). Querying only the legacy table
-            # flagged every used tray as missing on every Spoolman-mode print
-            # (#1473). Both tables expose printer_id / ams_id / tray_id in the
-            # same shape, so _global_tray_from_assignment works on either.
-            legacy_rows = (
-                await db.execute(SpoolAssignment.__table__.select().where(SpoolAssignment.printer_id == printer_id))
-            ).fetchall()
-            spoolman_rows = (
-                await db.execute(
-                    SpoolmanSlotAssignment.__table__.select().where(SpoolmanSlotAssignment.printer_id == printer_id)
-                )
-            ).fetchall()
-            assigned_global_trays = {
-                _global_tray_from_assignment(row.ams_id, row.tray_id) for row in (*legacy_rows, *spoolman_rows)
-            }
+            # A tray is "assigned" if it has a row in the table the current
+            # mode uses. Both expose printer_id / ams_id / tray_id in the same
+            # shape, so _global_tray_from_assignment works on either.
+            #
+            # This read both tables and unioned them until #2812. That was
+            # correct while the inactive table was emptied on every mode
+            # toggle -- it is how #1473 was fixed, where querying only the
+            # legacy table flagged every tray as missing on a Spoolman print.
+            # Nothing is emptied now, so a union would let a leftover row in
+            # the mode you are *not* using vouch for a tray that has no
+            # assignment in the mode you are, and this notification exists
+            # precisely to catch that tray.
+            table = SpoolmanSlotAssignment if await spoolman_owns_assignments(db) else SpoolAssignment
+            rows = (await db.execute(table.__table__.select().where(table.printer_id == printer_id))).fetchall()
+            assigned_global_trays = {_global_tray_from_assignment(row.ams_id, row.tray_id) for row in rows}
 
             missing_global = sorted(used_global_trays - assigned_global_trays)
             if not missing_global:
                 return
 
-            state = printer_manager.get_status(printer_id)
-            missing_slots = []
-            for global_id in missing_global:
-                profile, color = _tray_profile_and_color_for_global_id(state, global_id)
-                missing_slots.append(
-                    {
-                        "slot": _slot_label_from_global_tray(global_id),
-                        "profile": profile,
-                        "color": color,
-                    }
-                )
-
-            await ws_manager.send_missing_spool_assignment(
-                printer_id=printer_id,
-                printer_name=printer_name,
-                missing_slots=missing_slots,
-            )
-
-            await notification_service.on_print_missing_spool_assignment(
-                printer_id=printer_id,
-                printer_name=printer_name,
-                missing_slots=missing_slots,
-                db=db,
-            )
+            await _send_missing_assignment_notification(printer_id, printer_name, missing_global, db)
     except Exception as e:
         logger.warning("Missing spool-assignment notification failed: %s", e)
+
+
+async def _send_missing_assignment_notification(
+    printer_id: int,
+    printer_name: str,
+    missing_global: list[int],
+    db,
+) -> None:
+    """Describe the unassigned trays and push them to the UI and the providers."""
+    state = printer_manager.get_status(printer_id)
+    missing_slots = []
+    for global_id in missing_global:
+        profile, color = _tray_profile_and_color_for_global_id(state, global_id)
+        missing_slots.append(
+            {
+                "slot": _slot_label_from_global_tray(global_id),
+                "profile": profile,
+                "color": color,
+            }
+        )
+
+    await ws_manager.send_missing_spool_assignment(
+        printer_id=printer_id,
+        printer_name=printer_name,
+        missing_slots=missing_slots,
+    )
+    await notification_service.on_print_missing_spool_assignment(
+        printer_id=printer_id,
+        printer_name=printer_name,
+        missing_slots=missing_slots,
+        db=db,
+    )
+
+
+async def notify_missing_spool_assignments_on_print_complete(
+    printer_id: int,
+    missing_global_trays: list[int],
+    db,
+    logger: logging.Logger,
+) -> None:
+    """Say so when a finished print could not debit a tray it drew from (#2812).
+
+    The print-start check above is predictive: it reads the mapping before the
+    job runs and warns about trays that have no assignment yet. It cannot cover
+    an assignment that disappears *during* a print, and nothing re-checked
+    afterwards -- so a print whose assignments existed at print start, and were
+    gone by the time it finished, resolved its 3MF, resolved its grams,
+    resolved its tray, skipped the debit at INFO, and reported success. The
+    reporter lost 65.49 g that way and only noticed because a spool's remaining
+    weight looked wrong.
+
+    This fires on realized loss rather than risk: the trays passed here are the
+    ones a completed print actually tried to charge and could not. A print that
+    was already warned at start will notify twice, which is the right trade --
+    the first says the weight may not be tracked, the second says it was not.
+
+    Takes the caller's session: this runs inside ``on_print_complete``'s
+    transaction, and opening a second one to read the printer's name would
+    deadlock against it on SQLite.
+    """
+    if not missing_global_trays:
+        return
+    try:
+        printer = await db.get(Printer, printer_id)
+        printer_name = printer.name if printer else f"Printer {printer_id}"
+        await _send_missing_assignment_notification(printer_id, printer_name, sorted(set(missing_global_trays)), db)
+    except Exception as e:  # noqa: BLE001 — a notification must not fail a completed print
+        logger.warning("Missing spool-assignment completion notification failed: %s", e)

+ 127 - 0
backend/app/services/spool_filament_preset.py

@@ -0,0 +1,127 @@
+"""Resolve which slicer filament preset a spool should use on a given nozzle.
+
+``Spool.slicer_filament`` is the spool's single, printer-agnostic answer. It is
+right until the same spool is used on two printer models, because a cloud or
+Orca preset is bound to a model (``@BBL X1C``): assigning that spool to an H2C
+writes a slot preset the H2C has no profile for. ``SpoolFilamentPreset`` stores
+the per-model exceptions and this module is the only thing that reads them, so
+the internal-inventory and Spoolman-inventory assign paths cannot drift apart
+the way they did before #1713.
+
+Resolution order, most specific first:
+
+    1. (printer_model, nozzle_diameter)  -- what the spool form writes, one
+                                            row per nozzle size
+    2. (printer_model, "")               -- a whole-model value; the form does
+                                            not write these, but the API accepts
+                                            them and they still resolve
+    3. ``Spool.slicer_filament``         -- what the spool carries today
+
+Every step is a plain equality match on stored strings; nothing is inferred
+from preset names. A model with no row at all resolves to step 3, which is
+exactly the behaviour every install has now, so a spool nobody has configured
+per-model behaves identically before and after this feature.
+"""
+
+from __future__ import annotations
+
+import logging
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.spool_filament_preset import SpoolFilamentPreset, SpoolmanFilamentPreset
+
+logger = logging.getLogger(__name__)
+
+# What ``resolve_*`` returns: (slicer_filament, slicer_filament_name).
+PresetPair = tuple[str | None, str | None]
+
+
+def _pick(
+    rows: list[SpoolFilamentPreset] | list[SpoolmanFilamentPreset],
+    printer_model: str | None,
+    nozzle_diameter: str | None,
+    fallback: PresetPair,
+) -> PresetPair:
+    """Apply the cascade to rows already fetched for one spool.
+
+    Split out so both spool flavours share it, and so callers that already
+    hold the rows (the spool form's read path) do not re-query.
+    """
+    model = (printer_model or "").strip()
+    if not model:
+        # No model means no way to be more specific than the spool's own value.
+        # This is the normal answer for a printer that has not reported yet.
+        return fallback
+
+    diameter = (nozzle_diameter or "").strip()
+    exact: PresetPair | None = None
+    model_default: PresetPair | None = None
+
+    for row in rows:
+        if row.printer_model != model:
+            continue
+        if diameter and row.nozzle_diameter == diameter:
+            exact = (row.slicer_filament, row.slicer_filament_name)
+        elif row.nozzle_diameter == "":
+            model_default = (row.slicer_filament, row.slicer_filament_name)
+
+    chosen = exact or model_default
+    if chosen is None:
+        return fallback
+    # A row that exists but carries no preset id is a deliberate "use nothing
+    # here", not a hole to fall through: the user picked the blank entry for
+    # this model. Falling back would silently reinstate the value they cleared.
+    return chosen
+
+
+def printer_safe_filament_id(*candidates: str | None) -> str:
+    """First candidate the printer will accept as a filament id, or "".
+
+    ``extrusion_cali_sel`` carries a filament id so the printer can link the
+    calibration index to the slot's filament. A cloud USER preset id
+    (``PFUS``/``PFCN`` prefix) is not one the slicer accepts -- the assign paths
+    have refused those for tray_info_idx since #1713, and the same holds here.
+
+    This matters now that a per-model override can BE such an id: a user picking
+    their own cloud preset for a model stores its ``PFUS...`` id, and passing
+    that straight through would send the printer a value it rejects, silently
+    losing the K-profile link. Falls through to the next candidate instead --
+    normally the spool's own preset, then the tray's RFID value.
+    """
+    for candidate in candidates:
+        value = (candidate or "").strip()
+        if value and not value.startswith(("PFUS", "PFCN")):
+            return value
+    return ""
+
+
+async def resolve_spool_preset(
+    db: AsyncSession,
+    *,
+    spool_id: int,
+    printer_model: str | None,
+    nozzle_diameter: str | None,
+    fallback_filament: str | None,
+    fallback_name: str | None,
+) -> PresetPair:
+    """Cascade for an internal-inventory spool. See the module docstring."""
+    result = await db.execute(select(SpoolFilamentPreset).where(SpoolFilamentPreset.spool_id == spool_id))
+    return _pick(list(result.scalars().all()), printer_model, nozzle_diameter, (fallback_filament, fallback_name))
+
+
+async def resolve_spoolman_preset(
+    db: AsyncSession,
+    *,
+    spoolman_spool_id: int,
+    printer_model: str | None,
+    nozzle_diameter: str | None,
+    fallback_filament: str | None,
+    fallback_name: str | None,
+) -> PresetPair:
+    """Cascade for a Spoolman-managed spool. See the module docstring."""
+    result = await db.execute(
+        select(SpoolmanFilamentPreset).where(SpoolmanFilamentPreset.spoolman_spool_id == spoolman_spool_id)
+    )
+    return _pick(list(result.scalars().all()), printer_model, nozzle_diameter, (fallback_filament, fallback_name))

+ 105 - 19
backend/app/services/spool_tag_matcher.py

@@ -8,6 +8,9 @@ from sqlalchemy.orm import selectinload
 
 from backend.app.models.spool import Spool
 from backend.app.models.spool_assignment import SpoolAssignment
+from backend.app.schemas.spool import normalize_effect_type
+from backend.app.services.slot_nozzle import resolve_slot_nozzle
+from backend.app.services.spool_filament_preset import printer_safe_filament_id, resolve_spool_preset
 from backend.app.utils.tag_normalization import (
     normalize_tag_uid as _normalize_tag_uid,
     normalize_tray_uuid as _normalize_tray_uuid,
@@ -19,6 +22,13 @@ logger = logging.getLogger(__name__)
 ZERO_TAG_UID = "0000000000000000"
 ZERO_TRAY_UUID = "00000000000000000000000000000000"
 
+# Spool catalog row describing the reusable plastic spool Bambu Lab ships filament
+# on, and the weight to assume when that row is absent. DEFAULT_SPOOL_CATALOG holds
+# three "Bambu Lab%" rows (High Temp 216, Low Temp 250, White 253); this is the one
+# that matches the spool an RFID roll actually arrives on.
+BAMBU_PLASTIC_SPOOL_CATALOG_NAME = "Bambu Lab - Plastic Low Temp"
+BAMBU_PLASTIC_SPOOL_CORE_WEIGHT = 250
+
 
 def is_valid_tag(tag_uid: str, tray_uuid: str) -> bool:
     """Check if a tag/UUID pair contains a non-zero, non-empty value."""
@@ -99,6 +109,8 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
     # PLA Basic happens to come first in catalog insertion order. See #1227.
     rgba = tray_color if tray_color else None
     color_name = None
+    extra_colors = None
+    effect_type = None
 
     # Transparent filament (#1545): the AMS reports alpha=00 for clear spools.
     # Skip the catalog lookup — the catalog only stores RGB so 000000 would
@@ -124,6 +136,13 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
         entry = cat_result.scalar_one_or_none()
         if entry:
             color_name = entry.color_name
+            # The same row the spool form's colour picker reads. It hands
+            # `extra_colors` and `effect_type` to the new spool when a user
+            # picks a colour by hand (ColorSection.selectColor), and this path
+            # was taking the name alone -- so a roll added by hand rendered
+            # its gradient and a roll the AMS identified for you did not.
+            extra_colors = entry.extra_colors
+            effect_type = entry.effect_type
 
     # If tray_id_name is a human-readable name (no "-" code), fall back to it.
     if not color_name and tray_id_name and "-" not in tray_id_name:
@@ -136,13 +155,52 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
         color_name,
     )
 
-    # Look up core weight from spool catalog
-    core_weight = 250  # Default for Bambu Lab plastic spools
-    cat_result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.name.ilike("Bambu Lab%")).limit(10))
-    for entry in cat_result.scalars().all():
-        # Pick the best match (prefer exact, fallback to first Bambu Lab entry)
-        core_weight = entry.weight
-        break
+    # Fall back to the subtype for the swatch's rendering hint. `effect_type`
+    # is a visual variant kept independent of `subtype` so a user can override
+    # how a roll is drawn without touching Bambu's categorical label -- but
+    # nothing had ever set it here, and the shipped colour catalogue carries no
+    # effect on any of its 600-odd rows, so in practice it was always NULL and
+    # every wood, silk, sparkle and gradient roll drew as a flat disc. The
+    # subtype is the answer where the catalogue has none: it is derived above
+    # from what the printer reports, and the two vocabularies already line up
+    # ("Wood", "Silk", "Dual Color" are values of both). A subtype that names
+    # no effect -- Basic, Tough, CF -- leaves it NULL, which is the honest
+    # answer rather than a guessed overlay.
+    # "Silk+" is the same finish as "Silk" with a plus on the product name, so
+    # the trailing sign is dropped on a second attempt rather than costing the
+    # roll its overlay.
+    if effect_type is None and subtype:
+        for candidate in (subtype, subtype.rstrip("+")):
+            try:
+                effect_type = normalize_effect_type(candidate)
+            except ValueError:
+                continue
+            break
+
+    # Look up core weight from the spool catalog by exact name. The previous
+    # "Bambu Lab%" prefix query had no matching step and no ORDER BY, so it took
+    # whichever of the three Bambu Lab rows the database returned first — High Temp
+    # (216 g) on SQLite, undefined on Postgres once the table has seen updates.
+    # core_weight is the tare in SpoolBuddy's weigh flow, so a wrong value here
+    # silently biases every scale weighing of an RFID-created spool. See #2909.
+    #
+    # Falling back to the constant rather than to another catalog row keeps a
+    # missing or renamed entry from reintroducing the arbitrary pick, and matching
+    # by name means a user who has corrected that row to their own measurement gets
+    # their value.
+    core_weight = BAMBU_PLASTIC_SPOOL_CORE_WEIGHT
+    core_weight_catalog_id = None
+    cat_query = (
+        select(SpoolCatalogEntry)
+        .where(func.upper(SpoolCatalogEntry.name) == BAMBU_PLASTIC_SPOOL_CATALOG_NAME.upper())
+        .order_by(SpoolCatalogEntry.id)
+        .limit(1)
+    )
+    cat_result = await db.execute(cat_query)
+    catalog_entry = cat_result.scalar_one_or_none()
+    if catalog_entry:
+        core_weight = catalog_entry.weight
+        core_weight_catalog_id = catalog_entry.id
 
     # Resolve slicer filament name from builtin table
     slicer_filament_name = None
@@ -173,9 +231,12 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
         subtype=subtype,
         color_name=color_name,
         rgba=rgba,
+        extra_colors=extra_colors,
+        effect_type=effect_type,
         brand="Bambu Lab",
         label_weight=label_weight,
         core_weight=core_weight,
+        core_weight_catalog_id=core_weight_catalog_id,
         weight_used=weight_used,
         slicer_filament=tray_info_idx or None,
         slicer_filament_name=slicer_filament_name,
@@ -507,24 +568,49 @@ async def auto_assign_spool(
     try:
         client = printer_manager.get_client(printer_id)
         if client:
-            # Apply K-profile if available
-            nozzle_diameter = "0.4"
-            if state and state.nozzles:
-                nd = state.nozzles[0].nozzle_diameter
-                if nd:
-                    nozzle_diameter = nd
-
-            matching_kp = None
+            # Which nozzle this slot feeds, resolved the same way every other
+            # slot-configuring path resolves it (services.slot_nozzle).
+            slot_nozzle = resolve_slot_nozzle(state, ams_id, tray_id, printer_manager.get_model(printer_id))
+            nozzle_diameter = slot_nozzle.diameter
+
+            # Prefer the profile calibrated for THIS hotend, falling back to one
+            # stored for the same nozzle size on the other. Before this the
+            # first row matching (printer, diameter) won outright with no
+            # extruder test at all -- on a dual-nozzle printer that is a coin
+            # toss between the two hotends, on the path that fires unattended
+            # every time an RFID spool is loaded.
+            exact_kp = None
+            fallback_kp = None
             for kp in spool.k_profiles:
-                if kp.printer_id == printer_id and kp.nozzle_diameter == nozzle_diameter:
-                    matching_kp = kp
+                if kp.printer_id != printer_id or kp.nozzle_diameter != nozzle_diameter:
+                    continue
+                if not slot_nozzle.flow_matches(kp.nozzle_type):
+                    continue
+                if slot_nozzle.extruder is not None and kp.extruder == slot_nozzle.extruder:
+                    exact_kp = kp
                     break
+                if fallback_kp is None:
+                    fallback_kp = kp
+            matching_kp = exact_kp or fallback_kp
+
+            # The id sent with extrusion_cali_sel has to name the preset the
+            # profile was calibrated under, and that preset can differ per
+            # printer model -- so it comes from the same cascade the assign
+            # paths use rather than straight off the spool.
+            model_filament, _ = await resolve_spool_preset(
+                db,
+                spool_id=spool.id,
+                printer_model=printer_manager.get_model(printer_id),
+                nozzle_diameter=nozzle_diameter,
+                fallback_filament=spool.slicer_filament,
+                fallback_name=spool.slicer_filament_name,
+            )
 
             if matching_kp and matching_kp.cali_idx is not None:
                 # The filament_id in extrusion_cali_sel must match the filament preset
                 # under which the K-profile was calibrated. Use spool.slicer_filament
                 # (the preset assigned in inventory), falling back to tray's RFID value.
-                cali_filament_id = spool.slicer_filament or tray_info_idx or ""
+                cali_filament_id = printer_safe_filament_id(model_filament, spool.slicer_filament, tray_info_idx)
                 client.extrusion_cali_sel(
                     ams_id=ams_id,
                     tray_id=tray_id,
@@ -551,7 +637,7 @@ async def auto_assign_spool(
                 # so the printer keeps its existing calibration selection.
                 live_cali_idx = tray.get("cali_idx")
                 if live_cali_idx is not None and live_cali_idx >= 0:
-                    cali_filament_id = spool.slicer_filament or tray_info_idx or ""
+                    cali_filament_id = printer_safe_filament_id(model_filament, spool.slicer_filament, tray_info_idx)
                     client.extrusion_cali_sel(
                         ams_id=ams_id,
                         tray_id=tray_id,

+ 140 - 21
backend/app/services/spoolman.py

@@ -9,6 +9,8 @@ from typing import Literal
 
 import httpx
 
+from backend.app.utils.color_utils import color_match_key, spoolman_color_hex
+
 logger = logging.getLogger(__name__)
 
 BAMBU_RFID_TAG_LENGTH = 32
@@ -106,6 +108,18 @@ class SpoolmanClient:
         # Per-spool locks for atomic read-modify-write in merge_spool_extra.
         # WeakValueDictionary: locks are GC'd once no coroutine holds a reference.
         self._extra_locks: weakref.WeakValueDictionary[int, asyncio.Lock] = weakref.WeakValueDictionary()
+        # Extra-field names this client has already registered with Spoolman.
+        # Bounded by the number of distinct keys Bambuddy writes, so it never
+        # grows with spool count; scoped to the instance so a client pointed at
+        # a different Spoolman starts over.
+        self._ensured_extra_fields: set[str] = set()
+        # Whether Spoolman's extra-field listing has been read once. Separate
+        # from the set above because the two answer different questions: the
+        # set is "which fields are known to exist", this is "have we asked".
+        # Without it a client registering three brand-new fields re-read the
+        # whole listing before each one.
+        self._extra_fields_listed = False
+        self._ensure_extra_lock = asyncio.Lock()
 
     async def _get_client(self) -> httpx.AsyncClient:
         """Get or create the HTTP client with connection pooling limits."""
@@ -350,9 +364,10 @@ class SpoolmanClient:
         if material:
             data["material"] = material
         if color_hex:
-            # Strip alpha channel if present (RRGGBBAA -> RRGGBB)
-            color_hex = color_hex[:6] if len(color_hex) >= 6 else color_hex
-            data["color_hex"] = color_hex
+            # Every create funnels through here, so this is where the stored shape
+            # is decided: six characters for an opaque spool, eight only when the
+            # alpha byte says the filament is translucent. See #2912.
+            data["color_hex"] = spoolman_color_hex(color_hex) or color_hex
         if color_name:
             data["color_name"] = color_name
         if weight:
@@ -403,6 +418,7 @@ class SpoolmanClient:
             data["comment"] = comment
         if extra:
             data["extra"] = extra
+            await self._ensure_extra_fields(extra)
 
         logger.debug("Creating spool in Spoolman: %s", data)
         try:
@@ -443,6 +459,7 @@ class SpoolmanClient:
             data["location"] = location
         if extra:
             data["extra"] = extra
+            await self._ensure_extra_fields(extra)
         data["last_used"] = datetime.now(timezone.utc).isoformat()
 
         response = await self._request_spool("PATCH", spool_id, json_body=data, operation="update")
@@ -700,6 +717,7 @@ class SpoolmanClient:
             data["location"] = location
         if extra is not None:
             data["extra"] = extra
+            await self._ensure_extra_fields(extra)
         if clear_spool_weight:
             data["spool_weight"] = None
         elif spool_weight is not None:
@@ -748,7 +766,12 @@ class SpoolmanClient:
     ) -> int:
         """Return the filament ID matching material/name/brand/color, creating it if absent."""
         name = f"{material} {subtype}".strip() if subtype else material
-        color = color_hex[:6].upper() if len(color_hex) >= 6 else color_hex.upper()
+        # One value in both roles. `color_match_key` returns the shape the colour
+        # would be stored as, so the key the loop below compares on and the value
+        # a new filament is created with are the same string by construction: an
+        # opaque spool keys and stores as six characters, a translucent one as
+        # eight, and neither can be conflated with the other (#2912).
+        color = color_match_key(color_hex)
 
         vendor_id: int | None = None
         if brand:
@@ -763,7 +786,7 @@ class SpoolmanClient:
         filaments = await self.get_filaments()
         for f in filaments:
             f_material = (f.get("material") or "").upper()
-            f_color = (f.get("color_hex") or "").upper()[:6]
+            f_color = color_match_key(f.get("color_hex"))
             f_vendor = f.get("vendor") or {}
             f_vendor_name = (f_vendor.get("name") or "").strip().lower()
 
@@ -936,6 +959,36 @@ class SpoolmanClient:
         """Register the 'tag' extra field in Spoolman if not present; returns True on success."""
         return await self.ensure_extra_field("tag")
 
+    async def _load_existing_extra_field_keys(self) -> set[str] | None:
+        """Keys of the spool extra fields Spoolman already has, or ``None`` when
+        the listing could not be read.
+
+        ``None`` and ``set()`` mean different things and the caller acts on the
+        difference: an empty set is "Spoolman has no extra fields", which means
+        every field Bambuddy needs must be created; ``None`` is "we could not
+        find out", where the only safe move is to fall back to attempting the
+        write blind.
+        """
+        try:
+            client = await self._get_client()
+            response = await client.get(f"{self.api_url}/field/spool")
+            if response.status_code != 200:
+                logger.debug(
+                    "Spoolman extra-field listing returned %s; falling back to blind registration",
+                    response.status_code,
+                )
+                return None
+            fields = response.json()
+        except Exception as e:  # noqa: BLE001 — registration is best-effort, see _ensure_extra_fields
+            logger.debug("Could not read Spoolman extra-field listing: %s", e)
+            return None
+        if not isinstance(fields, list):
+            return None
+        # Match on `key`, not `name`: `key` is the identifier the extra dict is
+        # written under and the one Bambuddy cares about, while `name` is the
+        # free-text label a user is free to change in Spoolman's UI.
+        return {f["key"] for f in fields if isinstance(f, dict) and isinstance(f.get("key"), str)}
+
     async def ensure_extra_field(self, name: str, field_type: str = "text") -> bool:
         """Register a custom extra field in Spoolman if not present.
 
@@ -943,17 +996,40 @@ class SpoolmanClient:
         with HTTP 400 ('Unknown extra field <name>.'), so any custom field
         Bambuddy persists alongside spools needs to be pre-registered.
         Idempotent — returns True if the field already exists.
+
+        Existence is read from ``GET /field/spool``, the whole-listing endpoint.
+        This used to probe ``GET /field/spool/{name}`` for one field at a time,
+        which Spoolman has never served: it declares only POST and DELETE at
+        that path, so the probe answered 405 every time and the check could
+        never succeed (issue #2983, reported by @ngreatorex).
+
+        Falling through to the POST on every call was worse than a wasted
+        request, because that endpoint is an upsert rather than a create. It
+        answered 200 whether or not the field was already there, so a field a
+        user had renamed, retyped or given a default in Spoolman's own UI was
+        silently reset to Bambuddy's version of it on every restart. Reading
+        the listing first is what lets an existing field be left alone.
         """
         try:
-            client = await self._get_client()
-
-            # Check if field already exists
-            response = await client.get(f"{self.api_url}/field/spool/{name}")
-            if response.status_code == 200:
-                logger.debug("Spoolman extra field %r already exists", name)
+            if name in self._ensured_extra_fields:
                 return True
 
-            # Field doesn't exist - create it
+            if not self._extra_fields_listed:
+                existing = await self._load_existing_extra_field_keys()
+                if existing is not None:
+                    # Bank the whole listing: the caller registers several
+                    # fields in a row, and each one it already has is a request
+                    # not sent and a user customisation not overwritten. Read
+                    # once per client — every field created after this point is
+                    # added to the set as it is created, so re-reading would
+                    # only ever confirm what we already know.
+                    self._ensured_extra_fields |= existing
+                    self._extra_fields_listed = True
+                    if name in existing:
+                        logger.debug("Spoolman extra field %r already exists", name)
+                        return True
+
+            client = await self._get_client()
             field_data = {
                 "name": name,
                 "field_type": field_type,
@@ -962,6 +1038,7 @@ class SpoolmanClient:
             response = await client.post(f"{self.api_url}/field/spool/{name}", json=field_data)
             if response.status_code in (200, 201):
                 logger.info("Created Spoolman extra field %r", name)
+                self._ensured_extra_fields.add(name)
                 return True
 
             logger.warning(
@@ -976,6 +1053,43 @@ class SpoolmanClient:
             logger.warning("Failed to ensure Spoolman extra field %r exists: %s", name, e)
             return False
 
+    async def _ensure_extra_fields(self, extra: dict | None) -> None:
+        """Register every extra key an outgoing write declares, once per client.
+
+        Spoolman answers HTTP 400 "Unknown extra field <name>." for any extra
+        key that was not registered first, so registration has to happen before
+        the write, not before the feature. It used to happen before the feature:
+        three hand-maintained lists (the connect route, startup, and two inline
+        blocks in the inventory routes) each named the fields they expected to
+        be written later. Enabling Spoolman from Settings reaches none of them,
+        so the first AMS sync on a fresh Spoolman failed on every slot -- and
+        the Connect button that would have registered them is hidden by then,
+        because saving the settings initialises the client and the status
+        endpoint reads that as "connected" (issue #2903).
+
+        Keying off the payload instead removes the chance to forget: a write
+        that carries a key is a write that registers it. ``bambu_color_name``
+        is the cautionary case -- it never made it into the connect or startup
+        lists, and only works today because two call sites remembered to
+        register it by hand.
+
+        Best-effort by design. ``ensure_extra_field`` logs and returns False
+        rather than raising, and a failure here must not turn a write that
+        might still succeed into one that never happens -- the caller's own
+        error handling stays exactly as it was.
+        """
+        names = [name for name in (extra or {}) if name not in self._ensured_extra_fields]
+        if not names:
+            return
+
+        async with self._ensure_extra_lock:
+            for name in names:
+                # Re-check under the lock: a concurrent write may have just
+                # registered this one, and two syncs racing to POST the same
+                # field is how one of them gets a needless warning logged.
+                if name not in self._ensured_extra_fields:
+                    await self.ensure_extra_field(name)
+
     def parse_ams_tray(self, ams_id: int, tray_data: dict) -> AMSTray | None:
         """Parse raw MQTT tray data into an AMSTray; returns None for empty or invalid trays."""
         # Skip empty trays - check for valid tray_type
@@ -1151,7 +1265,7 @@ class SpoolmanClient:
                         material=tray.tray_type,
                         subtype="",
                         brand=brand,
-                        color_hex=tray.tray_color[:6],
+                        color_hex=tray.tray_color,
                         label_weight=tray.tray_weight,
                     )
                 except (SpoolmanNotFoundError, SpoolmanUnavailableError, SpoolmanClientError):
@@ -1201,9 +1315,12 @@ class SpoolmanClient:
     async def _find_or_create_filament(self, tray: AMSTray) -> dict | None:
         """Return a Bambu Lab filament matching the tray's material/color, creating it if absent."""
         bambu_vendor_id = await self.ensure_bambu_vendor()
-        color_hex = tray.tray_color[:6]  # Strip alpha channel
         material_upper = tray.tray_type.upper()
-        color_upper = color_hex.upper()
+        # Same single value as the user-driven path: the match key is the stored
+        # shape. That is what lets an opaque tray still find the six-character
+        # filaments every existing instance is full of, while a clear tray keys
+        # to eight and gets its own record (#2912).
+        color = color_match_key(tray.tray_color)
 
         # Search internal filaments - only match Bambu Lab vendor
         filaments = await self.get_filaments()
@@ -1212,8 +1329,7 @@ class SpoolmanClient:
             if fil_vendor_id != bambu_vendor_id:
                 continue
             fil_material = filament.get("material") or ""
-            fil_color = filament.get("color_hex") or ""
-            if fil_material.upper() == material_upper and fil_color.upper() == color_upper:
+            if fil_material.upper() == material_upper and color_match_key(filament.get("color_hex")) == color:
                 return filament
 
         # Search external filaments (SpoolmanDB) — restrict to Bambu Lab only.
@@ -1229,8 +1345,7 @@ class SpoolmanClient:
             if manufacturer != "bambu lab" and not ext_id.startswith("bambulab_"):
                 continue
             fil_material = filament.get("material") or ""
-            fil_color = filament.get("color_hex") or ""
-            if fil_material.upper() == material_upper and fil_color.upper() == color_upper:
+            if fil_material.upper() == material_upper and color_match_key(filament.get("color_hex")) == color:
                 bambu_candidates.append(filament)
 
         if bambu_candidates:
@@ -1248,7 +1363,7 @@ class SpoolmanClient:
             name=tray.tray_sub_brands or tray.tray_type,
             vendor_id=bambu_vendor_id,
             material=tray.tray_type,
-            color_hex=color_hex,
+            color_hex=color,
             weight=tray.tray_weight,
         )
 
@@ -1259,7 +1374,11 @@ class SpoolmanClient:
             name=external.get("name", tray.tray_sub_brands),
             vendor_id=vendor_id,
             material=external.get("material", tray.tray_type),
-            color_hex=external.get("color_hex", tray.tray_color[:6]),
+            # `or`, not a two-argument get: an entry that carries the key with an
+            # explicit null would hand None to create_filament rather than reach
+            # the tray fallback. Only a candidate when the tray colour is empty
+            # too, so this is a correctness tidy, not a fix for a live path.
+            color_hex=external.get("color_hex") or color_match_key(tray.tray_color),
             weight=external.get("weight", tray.tray_weight),
             density=external.get("density"),
         )

+ 406 - 30
backend/app/services/spoolman_tracking.py

@@ -7,6 +7,8 @@ Supports accurate partial usage reporting for failed/cancelled prints.
 
 import json
 import logging
+import math
+from dataclasses import dataclass
 
 from sqlalchemy import delete, select
 
@@ -40,6 +42,11 @@ _ZERO_TAG_UID = "0000000000000000"
 _MAX_REAL_TRAY_ID = 254
 
 
+def _is_real_tray_id(value) -> bool:
+    """True when ``value`` names a physical slot rather than "nothing loaded"."""
+    return isinstance(value, int) and not isinstance(value, bool) and 0 <= value <= _MAX_REAL_TRAY_ID
+
+
 def _is_non_zero_identifier(value: str) -> bool:
     """Return True when identifier is non-empty and not all zeros."""
     if not value:
@@ -163,7 +170,73 @@ 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]:
+def _single_slot_tray_from_state(
+    state,
+    filament_usage: list[dict],
+    tray_now_at_start: int | None = None,
+) -> tuple[int, int] | None:
+    """The tray a single-slot print actually drew from, read off the printer.
+
+    A1, A1 mini, P1S and P2S publish no ``mapping`` field and drop the MQTT
+    connection when we subscribe to their request topic, so neither of the
+    other two fallbacks can answer for them. What they do report is which tray
+    the extruder is fed from, and for a print that uses exactly one filament
+    slot that is the same question: the one slot came from the one tray.
+
+    The ladder mirrors ``usage_tracker.on_print_complete`` step 5, which has
+    consulted these same fields since it started resolving mappings at
+    completion. Spoolman users were the only ones not getting them (#2953).
+
+    Gated on exactly one slot with usage, like the internal writer: on a
+    multi-colour print every filament change moves ``tray_now``, so a single
+    tray reading says nothing about which slot it belongs to.
+
+    More than one tray-change entry means the print switched trays mid-run
+    (AMS backup on runout, #957). ``report_usage`` splits those per segment
+    and must not be handed a single-tray mapping instead, so this declines.
+
+    Returns ``(slot_id, global_tray_id)``, or None when the printer offered no
+    usable reading and the positional default stands.
+    """
+    nonzero = [u for u in filament_usage or [] if u.get("used_g", 0) > 0]
+    if len(nonzero) != 1:
+        return None
+    slot_id = nonzero[0].get("slot_id", 0)
+    if slot_id <= 0:
+        return None
+
+    changes = list(getattr(state, "tray_change_log", None) or [])
+    if len(changes) > 1:
+        return None
+    if len(changes) == 1:
+        entry = changes[0]
+        if isinstance(entry, (tuple, list)) and entry and _is_real_tray_id(entry[0]):
+            # Strongest evidence there is: the printer announced this switch
+            # while the job was running, so it describes this print and no
+            # other. On the reporter's A1 it read (3, 0) -- tray 3 at layer 0
+            # -- while the positional default was charging tray 0.
+            return slot_id, entry[0]
+
+    # No mid-print switch recorded. Fall back to the standing tray readings,
+    # newest evidence first. ``tray_now`` is 255 both at rest and while
+    # nothing is loaded, which is why _MAX_REAL_TRAY_ID excludes it; A1
+    # firmware parks there the moment a print ends, leaving last_loaded_tray
+    # as the only survivor.
+    for candidate in (
+        tray_now_at_start,
+        getattr(state, "tray_now", None),
+        getattr(state, "last_loaded_tray", None),
+    ):
+        if _is_real_tray_id(candidate):
+            return slot_id, candidate
+    return None
+
+
+def _resolve_slot_to_tray_fallback(
+    printer_id: int,
+    filament_usage: list[dict],
+    tray_now_at_start: int | None = None,
+) -> 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
@@ -180,9 +253,17 @@ def _resolve_slot_to_tray_fallback(printer_id: int, filament_usage: list[dict])
     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.
+    the loaded trays instead. Failing both, a print that used a single filament
+    slot can be pinned to the tray the printer reported feeding from
+    (``_single_slot_tray_from_state``).
+
+    The built-in inventory writer has consulted all three for as long as it has
+    resolved mappings at completion. The first version of this function offered
+    only the first two, which left A1-class printers -- no ``mapping`` field, no
+    request topic -- with nothing but the colour match, and that needs the
+    slicer's filament colour to equal the tray's exactly. A generic black
+    profile against a tray set to #111111 does not match, and the print is
+    charged to whichever spool happens to sit in the first tray (#2953).
 
     Deliberately at completion rather than inside ``store_print_data``: the
     printer keeps publishing ``mapping`` long after a job ends — it is still in
@@ -193,28 +274,46 @@ def _resolve_slot_to_tray_fallback(printer_id: int, filament_usage: list[dict])
 
     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.
+        filament_usage: The 3MF's per-slot estimates. The colour match reads
+            ``slot_id``/``color``; the tray-state fallback reads
+            ``slot_id``/``used_g``.
+        tray_now_at_start: The tray the printer was feeding from when the print
+            began, as captured by ``store_print_data``. Only consulted by the
+            tray-state fallback.
 
     Returns:
-        ``(mapping, source)``, or ``(None, "none")`` when neither fallback
-        produced anything and the positional default stands.
+        ``(mapping, source)``, or ``(None, "none")`` when no 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"
+    # Both of the first two fallbacks read the status payload; the third reads
+    # fields ``bambu_mqtt`` maintains on the state object itself, so an empty
+    # payload must not short-circuit past it.
+    if raw_data:
+        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"
+
+    single = _single_slot_tray_from_state(state, filament_usage, tray_now_at_start)
+    if single is not None:
+        slot_id, global_tray_id = single
+        # Only the one slot is claimed. The -1 padding is the array's existing
+        # "not an AMS tray" value, and the slots carrying it consumed nothing,
+        # so no caller resolves them: ``_report_spool_usage_for_slots`` skips
+        # zero-gram slots before resolving, ``_print_used_tray_keys`` skips
+        # negatives, and report_usage's handled-set skips them too.
+        mapping = [-1] * slot_id
+        mapping[slot_id - 1] = global_tray_id
+        return mapping, "tray_state"
 
     return None, "none"
 
@@ -565,6 +664,104 @@ async def _resolve_spool_id_via_slot_assignment(printer_id: int, ams_id: int, tr
         return result.scalar_one_or_none()
 
 
+def _as_positive_number(value) -> float | None:
+    """``value`` as a float when it is a usable positive quantity, else None.
+
+    Rejects bools (``True`` is an int in Python, and ``float(True)`` is 1.0 --
+    a weight of 1 g would price a spool per-gram at its whole cost), and
+    rejects NaN and infinity, which compare False against every bound and would
+    otherwise reach the archive as a NaN cost that no later comparison can
+    clear.
+    """
+    if isinstance(value, bool):
+        return None
+    try:
+        number = float(value)
+    except (TypeError, ValueError):
+        return None
+    if not math.isfinite(number) or number <= 0:
+        return None
+    return number
+
+
+def _spool_cost_per_gram(spool: dict | None) -> float | None:
+    """What one gram off this Spoolman spool costs, or None if it can't be said.
+
+    Spoolman prices a spool in two places. ``filament.price`` is the catalogue
+    figure for a full spool of that filament, and ``price`` on the spool itself
+    overrides it when a particular purchase cost something else -- a sale, a
+    different vendor, import duty. The spool's own value wins, which is the
+    order the Spoolman UI presents them in.
+
+    The divisor is ``filament.weight``: net filament grams, excluding the core.
+    That is the same field the remain-delta path already divides by to turn a
+    remain%% drop into grams, so a spool that can be charged by percentage can
+    always be priced too.
+
+    A missing or non-positive price is not a free spool, it is an unpriced one,
+    and returns None so the caller can fall back to the global default rate
+    rather than silently recording that this print cost nothing. Mirrors the
+    ``cost_per_kg > 0`` guard the built-in inventory writer applies to its own
+    per-spool rate.
+    """
+    if not isinstance(spool, dict):
+        return None
+    filament = spool.get("filament")
+    if not isinstance(filament, dict):
+        filament = {}
+
+    # A spool-level 0 is treated as "not overridden" rather than "this roll was
+    # free": Spoolman leaves the field null when unset, but an import or an API
+    # client that writes 0 instead is common enough that reading it as free
+    # would price a whole print at the default rate while a perfectly good
+    # catalogue price sat one level down.
+    raw_price = spool.get("price")
+    if _as_positive_number(raw_price) is None:
+        raw_price = filament.get("price")
+
+    price = _as_positive_number(raw_price)
+    weight = _as_positive_number(filament.get("weight"))
+    if price is None or weight is None:
+        return None
+    # Both operands can be finite and the quotient still overflow. A non-finite
+    # rate would reach the archive as a NaN or inf cost, and every later
+    # comparison against it is False, so nothing downstream would correct it.
+    rate = price / weight
+    return rate if math.isfinite(rate) else None
+
+
+@dataclass
+class _PrintCost:
+    """What a print cost, accumulated as each slot is actually charged.
+
+    Only grams that were both charged to a spool *and* priced from it are
+    counted. Everything else -- a slot whose spool has no price, a tray with no
+    Spoolman row at all, filament the 3MF never attributed -- is left for the
+    caller to cover at the global default rate, in one subtraction against the
+    archive's own total. That is the same shape as the built-in inventory
+    writer's untracked-grams top-up (#1344), and it means a partially priced
+    print reports a whole-print figure rather than only the priced share.
+    """
+
+    cost: float = 0.0
+    priced_grams: float = 0.0
+    priced: int = 0
+    unpriced: int = 0
+
+    def add(self, grams: float, spool: dict | None, label: str) -> None:
+        """Price ``grams`` off ``spool``. Call only after the charge succeeded."""
+        if grams <= 0:
+            return
+        rate = _spool_cost_per_gram(spool)
+        if rate is None:
+            self.unpriced += 1
+            logger.debug("[SPOOLMAN] %s: spool has no usable price, will fall back to the default rate", label)
+            return
+        self.cost += grams * rate
+        self.priced_grams += grams
+        self.priced += 1
+
+
 async def _report_spool_usage_for_slots(
     client,
     filament_usage_items: list[tuple[int, float]],
@@ -575,6 +772,7 @@ async def _report_spool_usage_for_slots(
     printer_id: int | None = None,
     slot_colors_out: dict[int, str] | None = None,
     slot_materials_out: dict[int, str] | None = None,
+    cost_out: _PrintCost | None = None,
 ) -> int:
     """Report usage to Spoolman for a list of (slot_id, grams) pairs.
 
@@ -621,6 +819,9 @@ async def _report_spool_usage_for_slots(
         # yields an id and is fetched below.
         spool_color_hex: str | None = None
         spool_material: str | None = None
+        # Full spool row, kept so the price fields (#2591) can be read from the
+        # same fetch the colour and material already pay for.
+        spool_obj: dict | None = None
 
         spool_tag = _resolve_spool_tag(tray_info, printer_serial, global_tray_id)
         if spool_tag:
@@ -628,6 +829,7 @@ async def _report_spool_usage_for_slots(
             if spool:
                 spool_id_to_use = spool["id"]
                 resolution_path = "tag"
+                spool_obj = spool
                 spool_color_hex = (spool.get("filament") or {}).get("color_hex")
                 spool_material = (spool.get("filament") or {}).get("material")
 
@@ -650,17 +852,19 @@ async def _report_spool_usage_for_slots(
         # id, so fetch the spool once for whichever value is still missing.
         # Strictly best-effort: a fetch failure must never abort the weight
         # reporting for the remaining slots, so the catch is broad.
-        if slot_colors_out is not None or slot_materials_out is not None:
+        if slot_colors_out is not None or slot_materials_out is not None or cost_out is not None:
             need_color = slot_colors_out is not None and spool_color_hex is None
             need_material = slot_materials_out is not None and spool_material is None
-            if need_color or need_material:
+            need_price = cost_out is not None and spool_obj is None
+            if need_color or need_material or need_price:
                 try:
-                    _fil = (await client.get_spool(spool_id_to_use)).get("filament") or {}
+                    spool_obj = await client.get_spool(spool_id_to_use)
+                    _fil = spool_obj.get("filament") or {}
                     if need_color:
                         spool_color_hex = _fil.get("color_hex")
                     if need_material:
                         spool_material = _fil.get("material")
-                except Exception as exc:  # noqa: BLE001 — colour/material are non-critical
+                except Exception as exc:  # noqa: BLE001 — colour/material/price are non-critical
                     logger.debug("[SPOOLMAN] Slot %s: could not fetch spool filament: %s", slot_id, exc)
             if slot_colors_out is not None and spool_color_hex:
                 slot_colors_out[slot_id] = spool_color_hex
@@ -678,6 +882,10 @@ async def _report_spool_usage_for_slots(
                 resolution_path,
             )
             spools_updated += 1
+            # Priced only after the charge landed, so a spool Spoolman refused
+            # cannot contribute to what the print is said to have cost.
+            if cost_out is not None:
+                cost_out.add(grams_used, spool_obj, f"Slot {slot_id}")
         except (SpoolmanNotFoundError, SpoolmanClientError, SpoolmanUnavailableError) as exc:
             logger.warning("[SPOOLMAN] Failed to record usage for spool %s: %s", spool_id_to_use, exc)
 
@@ -698,6 +906,7 @@ async def _report_spool_usage_split_by_tray_changes(
     printer_id: int,
     slot_colors_out: dict[int, str] | None = None,
     slot_materials_out: dict[int, str] | None = None,
+    cost_out: _PrintCost | None = None,
 ) -> tuple[int, set[int]]:
     """Split each slot's grams across ``tray_changes`` and charge per-segment.
 
@@ -751,6 +960,7 @@ async def _report_spool_usage_split_by_tray_changes(
             resolution_path = ""
             spool_color_hex: str | None = None
             spool_material: str | None = None
+            spool_obj: dict | None = None
 
             spool_tag = _resolve_spool_tag(tray_info, printer_serial, tray_global) if tray_info else ""
             if spool_tag:
@@ -758,6 +968,7 @@ async def _report_spool_usage_split_by_tray_changes(
                 if spool:
                     spool_id_to_use = spool["id"]
                     resolution_path = "tag"
+                    spool_obj = spool
                     spool_color_hex = (spool.get("filament") or {}).get("color_hex")
                     spool_material = (spool.get("filament") or {}).get("material")
 
@@ -785,14 +996,19 @@ async def _report_spool_usage_split_by_tray_changes(
             need_material = (
                 slot_materials_out is not None and slot_id not in slot_materials_out and spool_material is None
             )
-            if need_color or need_material:
+            # Unlike the colour, every segment needs its own price: each was
+            # charged to its own spool, and a backup roll can have cost
+            # something different from the one it replaced.
+            need_price = cost_out is not None and spool_obj is None
+            if need_color or need_material or need_price:
                 try:
-                    _fil = (await client.get_spool(spool_id_to_use)).get("filament") or {}
+                    spool_obj = await client.get_spool(spool_id_to_use)
+                    _fil = spool_obj.get("filament") or {}
                     if need_color:
                         spool_color_hex = _fil.get("color_hex")
                     if need_material:
                         spool_material = _fil.get("material")
-                except Exception as exc:  # noqa: BLE001 — colour/material are non-critical
+                except Exception as exc:  # noqa: BLE001 — colour/material/price are non-critical
                     logger.debug("[SPOOLMAN] Split slot %s: could not fetch spool filament: %s", slot_id, exc)
             if slot_colors_out is not None and slot_id not in slot_colors_out and spool_color_hex:
                 slot_colors_out[slot_id] = spool_color_hex
@@ -812,6 +1028,8 @@ async def _report_spool_usage_split_by_tray_changes(
                     resolution_path,
                 )
                 spools_updated += 1
+                if cost_out is not None:
+                    cost_out.add(round(segment_grams, 2), spool_obj, f"Split slot {slot_id} seg {seg_idx}")
             except (SpoolmanNotFoundError, SpoolmanClientError, SpoolmanUnavailableError) as exc:
                 logger.warning(
                     "[SPOOLMAN] Split slot %s seg %s: failed to record usage for spool %s: %s",
@@ -929,7 +1147,11 @@ async def _report_partial_usage(
     # ``_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)
+        slot_to_tray, _partial_mapping_source = _resolve_slot_to_tray_fallback(
+            printer_id,
+            filament_usage,
+            getattr(tracking, "tray_now_at_start", None),
+        )
         logger.info(
             "[SPOOLMAN] Partial usage: slot_to_tray=%s (source: %s)",
             slot_to_tray,
@@ -1124,16 +1346,40 @@ async def report_usage(printer_id: int, archive_id: int):
         # 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)
+            slot_to_tray, mapping_source = _resolve_slot_to_tray_fallback(printer_id, filament_usage, tray_now_at_start)
         logger.info(
             "[SPOOLMAN] Archive %s: slot_to_tray=%s (source: %s)",
             archive_id,
             slot_to_tray,
             mapping_source,
         )
+        # Nothing named a tray for this print and no fallback could recover
+        # one, so every slot is about to be resolved by position -- slicer slot
+        # 1 to the first loaded tray, and so on. That guess is right for an AMS
+        # loaded in slicer order and wrong for any other, and the caller has no
+        # way to tell which it got. Say so at a level that survives the default
+        # log filter, so a support bundle carries the reason (#2953).
+        #
+        # Excludes the tray-split path. It never reads ``slot_to_tray`` at all:
+        # it charges each segment to the tray the printer announced switching
+        # to, which is the same evidence the tray-state fallback is built on and
+        # is not a guess. Calling it one would suppress the archive rewrite for
+        # exactly the prints -- an AMS-backup runout on a Studio job (#1793 in
+        # #2768's conditions) -- whose attribution is best supported.
+        mapping_is_guess = bool(filament_usage) and mapping_source == "none" and len(tray_changes) <= 1
+        if mapping_is_guess:
+            logger.warning(
+                "[SPOOLMAN] Archive %s: no slot-to-tray mapping from any source -- "
+                "charging by tray position, which is a guess. Verify the spool weights "
+                "if the AMS is not loaded in slicer order.",
+                archive_id,
+            )
 
         slot_colors: dict[int, str] = {}
         slot_materials: dict[int, str] = {}
+        # Priced as each charge lands, so the figure the archive ends up with
+        # describes the same grams Spoolman actually had deducted (#2591).
+        print_cost = _PrintCost()
         handled_global_tray_ids: set[int] = set()
         spools_updated = 0
 
@@ -1179,6 +1425,7 @@ async def report_usage(printer_id: int, archive_id: int):
                     printer_id=printer_id,
                     slot_colors_out=slot_colors,
                     slot_materials_out=slot_materials,
+                    cost_out=print_cost,
                 )
                 spools_updated += split_updated
                 handled_global_tray_ids |= split_handled
@@ -1195,10 +1442,18 @@ async def report_usage(printer_id: int, archive_id: int):
                     printer_id=printer_id,
                     slot_colors_out=slot_colors,
                     slot_materials_out=slot_materials,
+                    cost_out=print_cost,
                 )
                 # Track which physical slots the 3MF path already covered so
                 # Path 2 doesn't double-charge them.
                 for u in filament_usage:
+                    if u.get("used_g", 0) <= 0:
+                        # ``_report_spool_usage_for_slots`` skipped this slot
+                        # before resolving a tray for it, so nothing was
+                        # charged and Path 2 is free to cover the slot from
+                        # remain% -- claiming it here would suppress a real
+                        # drop on the strength of a zero-gram estimate.
+                        continue
                     slot_id = u.get("slot_id", 0)
                     handled_global_tray_ids.add(_resolve_global_tray_id(slot_id, slot_to_tray, ams_trays))
 
@@ -1220,6 +1475,7 @@ async def report_usage(printer_id: int, archive_id: int):
                 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,
+                cost_out=print_cost,
             )
             spools_updated += fallback_updates
 
@@ -1231,11 +1487,33 @@ async def report_usage(printer_id: int, archive_id: int):
         # Stamp the archive's filament colour from the matched Spoolman spools
         # so it reflects the curated inventory colour, not the slicer's 3MF
         # value (#1494) — mirrors the built-in inventory path in usage_tracker.
-        await _apply_spool_colors_to_archive(db, archive_id, filament_usage, slot_colors)
+        #
+        # Skipped when the mapping was a positional guess. Charging the wrong
+        # spool costs grams the owner can put back; rewriting the archive's
+        # colour and material on top of it overwrites what the slicer actually
+        # recorded, and the print then reads as a different filament than the
+        # one that made it, with nothing left to compare against (#2953).
+        if mapping_is_guess:
+            if slot_colors or slot_materials:
+                logger.info(
+                    "[SPOOLMAN] Archive %s: leaving filament colour/type as sliced — "
+                    "the spools were matched by position, not by a known mapping",
+                    archive_id,
+                )
+        else:
+            await _apply_spool_colors_to_archive(db, archive_id, filament_usage, slot_colors)
+
+            # Same for the material: a slot mapped to a differently-typed spool
+            # than it was sliced for otherwise records the sliced type (#2563).
+            await _apply_spool_types_to_archive(db, archive_id, filament_usage, slot_materials)
 
-        # Same for the material: a slot mapped to a differently-typed spool than
-        # it was sliced for otherwise records the sliced type (#2563).
-        await _apply_spool_types_to_archive(db, archive_id, filament_usage, slot_materials)
+        # Cost is applied whether or not the mapping was a guess, unlike the
+        # colour and material above. Those overwrite what the slicer recorded,
+        # which is why a guess must not touch them; the cost has no such
+        # original -- archive.py's figure is itself derived from a default rate
+        # -- and the grams have already been deducted from these spools, so the
+        # archive should say what that deduction was worth.
+        await _apply_spool_cost_to_archive(db, archive_id, print_cost)
 
 
 def _print_used_tray_keys(
@@ -1292,6 +1570,7 @@ async def _report_remain_delta_for_slots(
     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,
+    cost_out: _PrintCost | None = None,
 ) -> int:
     """AMS remain%-delta path: write ``(start - current) * filament.weight``
     grams to Spoolman for slots the 3MF path didn't cover.
@@ -1409,6 +1688,10 @@ async def _report_remain_delta_for_slots(
             continue
 
         spools_updated += 1
+        # ``spool`` here is the full row fetched above for its filament weight,
+        # so the price is already in hand (#2591).
+        if cost_out is not None:
+            cost_out.add(grams_used, spool, f"AMS{ams_id}-T{tray_id}")
         # No 3MF slot_id for this path — use the AMS slot key so the maps can
         # still be inspected by callers if needed. The archive rewrites
         # (#1494 colour, #2563 type) key on 3MF slot_ids, so remain-delta-only
@@ -1441,6 +1724,99 @@ async def _report_remain_delta_for_slots(
     return spools_updated
 
 
+async def _apply_spool_cost_to_archive(db, archive_id: int, print_cost: _PrintCost) -> None:
+    """Set an archive's cost from what the Spoolman spools that fed it are worth (#2591).
+
+    Until now this was the one thing the Spoolman integration was asked for by
+    name and did not do. ``archive.py`` prices a print once, at archive time,
+    from the built-in Filament catalogue matched on the primary type, falling
+    back to the global default rate -- and in Spoolman mode nothing ever
+    revisited that figure, because the per-spool recompute in
+    ``usage_tracker.on_print_complete`` only runs over rows the built-in
+    inventory wrote and Spoolman mode writes none. An install with an empty
+    catalogue therefore priced every print at the default no matter what the
+    linked spool actually cost.
+
+    Multi-material was wrong twice over there: the primary type's rate applied
+    to the *whole* print's grams, so a slot of expensive PA came out at the
+    price of the PLA next to it. Summing per charged slot is what fixes that,
+    and it falls out of pricing each charge as it is made rather than pricing a
+    total afterwards.
+
+    Grams that could not be priced are covered at the global default rate in a
+    single subtraction against the archive's own total -- a slot whose spool has
+    no price, a tray with no Spoolman row, and filament the 3MF never attributed
+    are all the same case. Without it a print with one priced slot out of four
+    would report a quarter of its cost, which is #1344 in a different inventory
+    mode.
+
+    Only on the first run, matching the built-in writer (#1378): reprint actuals
+    live in ``PrintLogEntry``, and the archive card keeps the first run's figure
+    so a failed 10 g reprint doesn't visually clobber a successful 100 g print.
+
+    Does nothing when no slot could be priced, leaving whatever ``archive.py``
+    recorded. That keeps an install with prices in neither place exactly where
+    it was.
+    """
+    if print_cost.priced == 0:
+        if print_cost.unpriced:
+            logger.info(
+                "[SPOOLMAN] Archive %s: %d charged spool(s) carry no price -- "
+                "leaving the cost as recorded at archive time",
+                archive_id,
+                print_cost.unpriced,
+            )
+        return
+
+    from sqlalchemy import func
+
+    from backend.app.api.routes.settings import get_setting
+    from backend.app.models.archive import PrintArchive
+    from backend.app.models.print_log import PrintLogEntry
+
+    archive = (await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))).scalar_one_or_none()
+    if archive is None:
+        return
+
+    total = print_cost.cost
+    archive_grams = archive.filament_used_grams or 0
+    unpriced_grams = max(0.0, archive_grams - print_cost.priced_grams)
+    if unpriced_grams > 0:
+        # Malformed settings must not cost the whole usage report; the rate is
+        # the least important thing this pass produces.
+        try:
+            _setting = await get_setting(db, "default_filament_cost")
+            default_cost_per_kg = float(_setting) if _setting else 25.0
+        except (TypeError, ValueError):
+            default_cost_per_kg = 25.0
+        if default_cost_per_kg > 0:
+            total += (unpriced_grams / 1000.0) * default_cost_per_kg
+
+    if total <= 0:
+        return
+
+    existing_runs = (
+        await db.execute(select(func.count(PrintLogEntry.id)).where(PrintLogEntry.archive_id == archive_id))
+    ).scalar()
+    if existing_runs:
+        return
+
+    new_cost = round(total, 2)
+    if new_cost != archive.cost:
+        logger.info(
+            "[SPOOLMAN] Archive %s cost %s -> %s (%d slot(s) priced from Spoolman over %.2fg, "
+            "%.2fg at the default rate)",
+            archive_id,
+            archive.cost,
+            new_cost,
+            print_cost.priced,
+            print_cost.priced_grams,
+            unpriced_grams,
+        )
+        archive.cost = new_cost
+        await db.commit()
+
+
 async def _apply_spool_colors_to_archive(
     db,
     archive_id: int,

+ 133 - 2
backend/app/services/stl_thumbnail.py

@@ -54,6 +54,28 @@ def _configure_matplotlib_cache() -> None:
 BAMBU_GREEN = "#00AE42"
 BACKGROUND_COLOR = "#1a1a1a"
 
+# Direction of the synthetic light used to shade the mesh. Without a light
+# source ``Poly3DCollection`` fills every triangle with the identical colour
+# regardless of its normal, so the render comes out a flat silhouette and one
+# model is indistinguishable from another (issue #2816).
+#
+# The azimuth is NOT free. matplotlib's light direction for (az, alt) is
+# ``[cos(90-az)cos(alt), sin(90-az)cos(alt), sin(alt)]``, and the camera set by
+# ``view_init(elev, azim)`` sits at ``[cos(elev)cos(azim), cos(elev)sin(azim),
+# sin(elev)]``. The dot product of the two must be POSITIVE or the light is
+# behind the model: at 225 it is -0.34, which lights the two hidden faces and
+# gives both visible ones the identical 0.475 — a cube with no contrast down its
+# front edge. At 315 it is +0.30, and the two visible sides come out 0.825 and
+# 0.475. ``test_light_is_on_the_camera_side`` holds that invariant so the pair
+# cannot drift apart again.
+LIGHT_AZIMUTH_DEG = 315
+LIGHT_ALTITUDE_DEG = 45
+
+# The camera the light above is chosen against. Named because the two are a PAIR:
+# move one without the other and the model goes back to being lit from behind.
+VIEW_ELEV_DEG = 25
+VIEW_AZIM_DEG = 45
+
 # Maximum vertices before simplification
 MAX_VERTICES = 100000
 
@@ -66,6 +88,97 @@ MAX_VERTICES = 100000
 MIN_USABLE_STL_BYTES = 200
 
 
+def _repair_winding(mesh, trimesh, label: str) -> None:
+    """Make every face wind the same way, and wind it OUTWARD, before shading.
+
+    matplotlib derives its normals from vertex ORDER, so a triangle wound the
+    wrong way shades as though it faced away and the model comes out patchy —
+    camouflage rather than a surface. Unshaded this never showed, so lighting the
+    render is what makes it matter, and the File Manager takes arbitrary user
+    STLs. ``trimesh.load(force="mesh")`` does not repair winding; this does.
+
+    ``trimesh.repair.fix_winding`` and NOT ``mesh.fix_normals()``: the latter
+    reaches ``body_count`` -> ``scipy.csgraph``, and scipy is not a dependency of
+    this project. fix_winding goes through networkx, which requirements.txt
+    already pins.
+
+    Three steps, because each one leaves something for the next:
+
+    * ``fix_winding`` makes the winding agree but is free to settle on either
+      orientation, and on a half-inverted sphere it picks INWARD — consistent,
+      and consistently lit from inside.
+    * ``fix_inversion`` corrects that off the sign of the volume, but only for a
+      WATERTIGHT mesh. It returns early otherwise, because a volume measured
+      across holes says nothing about which way is out.
+    * Which leaves the common case, since a mesh with broken winding is usually
+      not watertight either. With no usable volume, decide by whether the faces
+      point away from the centroid. Measured on a punctured half-inverted
+      icosphere: the first two steps alone left 0 of 1200 faces oriented like the
+      correctly wound mesh, a mean render delta of 4.56; with this one it is
+      1200 of 1200 and 0.00.
+
+    The centroid test runs only on a mesh whose winding was already broken, and
+    it leaves correct ones alone: closed and punctured spheres, a flat plate, an
+    open tube, a non-convex L and two disjoint boxes all sum positive.
+
+    Gated here rather than at the call sites so the two renderers cannot drift.
+    The check is tens of ms where the repair is seconds on a large mesh, so only
+    meshes that would otherwise render wrong pay for it.
+    """
+    import numpy as np
+
+    if len(mesh.faces) == 0 or mesh.is_winding_consistent:
+        return
+
+    logger.debug("Repairing inconsistent winding before render: %s", label)
+    trimesh.repair.fix_winding(mesh)
+    trimesh.repair.fix_inversion(mesh)
+    if mesh.is_watertight:
+        return
+
+    outward = mesh.triangles.mean(axis=1) - mesh.vertices.mean(axis=0)
+    if float(np.einsum("ij,ij->i", mesh.face_normals, outward).sum()) < 0:
+        logger.debug("Winding settled inward on a non-watertight mesh, inverting: %s", label)
+        mesh.invert()
+
+
+def _shade_kwargs(poly3d, LightSource) -> dict:
+    """``shade=True`` and its light, or nothing when the mesh cannot be shaded.
+
+    matplotlib's ``_shade_colors`` has a fallback for a mesh whose every face
+    normal is degenerate, and that fallback returns the colour argument it was
+    given, unchanged. Passing a colour STRING — which both renderers do — makes
+    it hand back a 0-d ``<U7`` array, and ``to_rgba_array`` then calls ``len()``
+    on it and raises ``TypeError: len() of unsized object``.
+
+    So a file whose facets are all zero-area or collinear rendered fine while the
+    output was flat, and would fail outright once lit. That population is real:
+    stub and truncated STLs, and hand-written 3MFs with an empty ``<triangles/>``.
+    Worse, ``batch_generate_stl_thumbnails`` walks a whole folder with no
+    minimum-size pre-skip, so each one would count as a failure in the UI and put
+    a traceback in the log — the exact noise ``stl_thumbnail``'s demoted logging
+    exists to keep out.
+
+    Deciding here rather than catching the TypeError keeps the flat render as a
+    real outcome instead of an error path, and costs ~6 ms on a 227k-face mesh.
+    Identical to matplotlib's own test: a cross product that is finite and
+    non-zero for at least one face.
+    """
+    import numpy as np
+
+    if len(poly3d) == 0:
+        return {}
+    tri = np.asarray(poly3d, dtype=float)
+    normals = np.cross(tri[:, 0] - tri[:, 1], tri[:, 1] - tri[:, 2])
+    lengths = np.linalg.norm(normals, axis=1)
+    if not bool(np.any(np.isfinite(lengths) & (lengths > 0))):
+        return {}
+    return {
+        "shade": True,
+        "lightsource": LightSource(azdeg=LIGHT_AZIMUTH_DEG, altdeg=LIGHT_ALTITUDE_DEG),
+    }
+
+
 def generate_stl_thumbnail(
     stl_path: Path,
     thumbnails_dir: Path,
@@ -98,6 +211,7 @@ def generate_stl_thumbnail(
         # Use Agg backend for headless rendering
         matplotlib.use("Agg")
         import matplotlib.pyplot as plt
+        from matplotlib.colors import LightSource
         from mpl_toolkits.mplot3d import Axes3D  # noqa: F401
         from mpl_toolkits.mplot3d.art3d import Poly3DCollection
 
@@ -130,6 +244,14 @@ def generate_stl_thumbnail(
             except Exception as e:
                 logger.warning("Mesh simplification failed, using original: %s", e)
 
+        # Wind every face the same way, and outward, or the shading turns the
+        # model into camouflage. See ``_repair_winding``; it must run before the
+        # vertices below are read, since a future repair step could move them.
+        try:
+            _repair_winding(mesh, trimesh, str(stl_path))
+        except Exception as e:  # best-effort: a flat render beats no thumbnail
+            logger.debug("Winding repair skipped (%s): %s", e, stl_path)
+
         # Get mesh bounds and center it
         vertices = mesh.vertices
         bounds_min = vertices.min(axis=0)
@@ -153,15 +275,24 @@ def generate_stl_thumbnail(
         ax.set_facecolor(BACKGROUND_COLOR)
 
         # Create polygon collection from mesh faces
+        # Index with the face array rather than building a list of lists. Same
+        # data, and Poly3DCollection accepts it directly — but shading walks this
+        # structure to generate normals, and on an 82k-face mesh the list form
+        # costs ~0.19s against ~0.007s for the ndarray. It speeds up the unshaded
+        # path too.
         faces = mesh.faces
-        poly3d = [[vertices_scaled[vertex] for vertex in face] for face in faces]
+        poly3d = vertices_scaled[faces]
 
+        # ``shade=True`` needs a real ``edgecolors``: matplotlib shades the edge
+        # colours alongside the face colours, and an empty array (``"none"``)
+        # makes it raise on the broadcast. Keep the two in step if either moves.
         collection = Poly3DCollection(
             poly3d,
             facecolors=BAMBU_GREEN,
             edgecolors=BAMBU_GREEN,
             linewidths=0.1,
             alpha=0.9,
+            **_shade_kwargs(poly3d, LightSource),
         )
         ax.add_collection3d(collection)
 
@@ -171,7 +302,7 @@ def generate_stl_thumbnail(
         ax.set_zlim(-0.6, 0.6)
 
         # Set view angle (isometric-ish)
-        ax.view_init(elev=25, azim=45)
+        ax.view_init(elev=VIEW_ELEV_DEG, azim=VIEW_AZIM_DEG)
 
         # Remove axes and grid
         ax.set_axis_off()

+ 8 - 3
backend/app/services/timelapse_processor.py

@@ -7,6 +7,7 @@ import tempfile
 from pathlib import Path
 
 from backend.app.services.camera import get_ffmpeg_path
+from backend.app.utils.ffmpeg_output import NO_FFMPEG_OUTPUT, summarize_ffmpeg_stderr
 
 logger = logging.getLogger(__name__)
 
@@ -43,8 +44,12 @@ class TimelapseProcessor:
         stdout, stderr = await process.communicate()
 
         if process.returncode != 0:
-            logger.error("ffprobe failed: %s", stderr.decode())
-            raise RuntimeError(f"ffprobe failed: {stderr.decode()}")
+            # Summarised once and used for both: the raise carried a second,
+            # bare ``stderr.decode()`` that could itself raise UnicodeDecodeError
+            # on the bytes ffprobe copies out of a broken file (#2968).
+            detail = summarize_ffmpeg_stderr(stderr) or NO_FFMPEG_OUTPUT
+            logger.error("ffprobe failed: %s", detail)
+            raise RuntimeError(f"ffprobe failed: {detail}")
 
         data = json.loads(stdout.decode())
         video_stream = next(
@@ -230,7 +235,7 @@ class TimelapseProcessor:
         _, stderr = await process.communicate()
 
         if process.returncode != 0:
-            logger.error("FFmpeg processing failed: %s", stderr.decode())
+            logger.error("FFmpeg processing failed: %s", summarize_ffmpeg_stderr(stderr) or NO_FFMPEG_OUTPUT)
             return False
 
         return output_path.exists()

+ 241 - 21
backend/app/services/usage_tracker.py

@@ -10,6 +10,7 @@ AMS remain% delta is the fallback for trays not covered by 3MF data.
 import asyncio
 import json
 import logging
+import re
 from dataclasses import dataclass, field
 from datetime import datetime, timezone
 
@@ -738,7 +739,13 @@ async def on_print_complete(
 
         search_filename = data.get("filename") or data.get("subtask_name") or (session.print_name if session else "")
         if search_filename:
-            threemf_path = await _find_3mf_by_filename(printer_id, search_filename, db, app_settings.base_dir)
+            threemf_path = await _find_3mf_by_filename(
+                printer_id,
+                search_filename,
+                db,
+                app_settings.base_dir,
+                print_name=data.get("subtask_name") or (session.print_name if session else None),
+            )
 
     if archive_id or threemf_path:
         threemf_results = await _track_from_3mf(
@@ -830,6 +837,18 @@ async def on_print_complete(
                     continue  # Already tracked via 3MF
 
                 if key not in session.tray_remain_start:
+                    # No usable remain% when the print began, so there is no delta
+                    # to charge. Said out loud for the same reason as the branches
+                    # below: a slot the print used, holding a spool the operator
+                    # assigned, otherwise vanished from the accounting without a
+                    # word. Common on non-RFID spools, which report remain = -1
+                    # until a remaining amount is set by hand.
+                    if not print_used_keys or key in print_used_keys:
+                        logger.info(
+                            "[UsageTracker] %s: no valid remain%% at print start, nothing to charge for printer %d",
+                            tray_label,
+                            printer_id,
+                        )
                     continue
 
                 # Skip trays the print never touched. Only enforce when we have
@@ -994,32 +1013,165 @@ async def on_print_complete(
     return results
 
 
+# A running print's ``filename`` is the path the printer is executing, and on a
+# sliced job that is always ``…/Metadata/plate_<N>.gcode``. Its stem names the
+# *plate*, not the model, and every Bambu print in existence has one — so it
+# identifies nothing and must never be used to match a 3MF. It reached the
+# matcher for real on H2-series and P2S prints, where the file goes to internal
+# eMMC, no 3MF can be fetched, and the archive keeps the gcode path as its
+# filename: `plate_1` then matched an unrelated `lid_plate_1.gcode.3mf` and that
+# print's filament figures were read off a different model entirely.
+_GENERIC_PLATE_STEM = re.compile(r"^plate_?\d+$", re.IGNORECASE)
+
+
+def _like_escape(value: str) -> str:
+    """Escape LIKE metacharacters so a stem matches literally.
+
+    ``_`` is a single-character wildcard, and model names are full of them.
+    """
+    return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
+
+
+def _threemf_search_stem(*candidates: str | None) -> str | None:
+    """First candidate that names a model, or None if none of them do.
+
+    Candidates are tried in order and the generic plate name is skipped rather
+    than accepted, so a print that only has one falls through to "no match"
+    instead of matching everything.
+    """
+    for raw in candidates:
+        if not raw:
+            continue
+        stem = raw.split("/")[-1].strip()
+        for suffix in (".gcode.3mf", ".gcode", ".3mf"):
+            if stem.lower().endswith(suffix):
+                stem = stem[: -len(suffix)]
+                break
+        # Stripped only to judge the stem, never to change it: a real archive
+        # here is named "…Face Down .gcode.3mf", and a stem trimmed to
+        # "…Face Down" no longer matches the file it came from.
+        probe = stem.strip()
+        if probe and not _GENERIC_PLATE_STEM.match(probe):
+            return stem
+    return None
+
+
+def _stem_matches(column, stem: str):
+    """Filter matching *stem* at a filename boundary rather than anywhere.
+
+    ``ilike("%<stem>.%")`` also matched a *suffix* of a longer name, which is how
+    `plate_1` reached `lid_plate_1.gcode.3mf`. A name is either the whole
+    basename or the basename after a directory separator.
+    """
+    escaped = _like_escape(stem)
+    return column.ilike(f"{escaped}.%", escape="\\") | column.ilike(f"%/{escaped}.%", escape="\\")
+
+
+def _expected_plate_for_print(plate_id: int | None, gcode_file: str | None) -> int | None:
+    """The plate a running print is on, from whatever was recorded about it.
+
+    ``plate_id`` is the reliable source, and the archives that need a donor 3MF
+    have none: the no-3MF fallback row is created before any 3MF is read, so
+    the column is never filled. The gcode path the printer echoed is the other
+    source, exact on the firmwares that echo ``Metadata/plate_N.gcode``. Some
+    P1S builds echo only the 3MF filename, and then the plate is simply not
+    knowable at print start (#2957).
+    """
+    from backend.app.services.printer_manager import parse_plate_id
+
+    if plate_id is not None:
+        return plate_id
+    return parse_plate_id(gcode_file)
+
+
+def _donor_3mf_conflicts(candidate, expected_plate: int | None) -> str | None:
+    """Why *candidate* cannot be this print's 3MF, or None if nothing rules it out.
+
+    A same-name 3MF is not the same print. Bambu Studio writes the printer-side
+    filename from the project's ``Title`` metadata, so every plate of a project
+    arrives under one name however the user renamed the file on disk, and a
+    donor chosen on the name alone hands one plate's slicer estimates to another
+    plate's print. A reporter's single-filament job was charged against three
+    spools that way, and nothing about the deduction said it was a guess
+    (#2957).
+
+    The plate is the one thing that can settle this. It is the same comparison
+    #1204 already makes against a freshly downloaded 3MF, so a single-plate
+    export is known to carry its original index rather than a renumbered 1.
+
+    Filament *count* deliberately is not checked, however tempting: the slicer's
+    ``ams_mapping`` is indexed by the project's filament slot -- see
+    ``slot_to_tray[slot_id - 1]`` below -- not by the plate's, so a real
+    single-filament print reports ``[0, -1, -1, -1]`` and its length says
+    nothing about how many filaments the plate uses.
+    """
+    from backend.app.services.archive import plate_indexes_in_3mf
+
+    if expected_plate is None:
+        return None
+
+    plates = plate_indexes_in_3mf(candidate)
+    if not plates or any(plate is None for plate in plates):
+        # Nothing was read, or not all of it was, and neither is evidence about
+        # the plate. Refusing here would drop the fallback for every 3MF variant
+        # this parser does not understand; downstream reports that honestly as
+        # "no filament usage data".
+        return None
+    if len(plates) == 1 and plates[0] != expected_plate:
+        return f"it holds plate {plates[0]}, this print is plate {expected_plate}"
+    if expected_plate not in plates:
+        # An all-plates export is a good donor precisely when it carries the
+        # plate that is running. Without this the plate is looked for
+        # downstream, found missing, and the whole file's filaments are summed
+        # onto one plate's print.
+        return f"it has no plate {expected_plate}"
+    return None
+
+
 async def _resolve_3mf_fallback(archive, db: AsyncSession, base_dir):
     """Try to find a 3MF file from library or a previous archive when the current archive has none.
 
     This handles fallback archives (FTP download failed) where the 3MF may already exist
     locally from a library upload or a previous successful print of the same file.
+
+    A name match alone does not make a candidate this print's file, so every
+    candidate is put through :func:`_donor_3mf_conflicts` before it is handed
+    back (#2957).
     """
     from pathlib import Path
 
     from backend.app.models.archive import PrintArchive
     from backend.app.models.library import LibraryFile
 
-    # Derive search name from archive filename (e.g. "benchy.3mf" or "benchy.gcode.3mf")
-    search_name = archive.filename or archive.print_name
-    if not search_name:
-        return None
-    # Normalize: strip path parts, get base name
-    search_name = search_name.split("/")[-1]
-    search_base = search_name.replace(".gcode.3mf", "").replace(".gcode", "").replace(".3mf", "")
+    # Derive search name from archive filename (e.g. "benchy.3mf" or "benchy.gcode.3mf"),
+    # falling back to the print name when the filename is only a plate path.
+    search_base = _threemf_search_stem(archive.filename, archive.print_name)
     if not search_base:
         return None
 
+    print_data = (getattr(archive, "extra_data", None) or {}).get("_print_data") or {}
+    expected_plate = _expected_plate_for_print(
+        getattr(archive, "plate_id", None),
+        archive.filename or print_data.get("filename"),
+    )
+    if expected_plate is None:
+        # Worth saying out loud. On the firmwares that echo only the 3MF
+        # filename there is nothing to check a donor against, so whatever is
+        # accepted below is accepted on its name alone -- which is how the
+        # reporter's spools were debited for another plate's filament. The
+        # deduction being silent was half the bug (#2957).
+        logger.warning(
+            "[UsageTracker] 3MF fallback: archive %s does not know its plate (%r), so a same-named "
+            "3MF can only be matched on its name",
+            archive.id,
+            archive.filename,
+        )
+
     # 1. Try library files matching the name (match base name at file boundary)
     try:
         lib_result = await db.execute(
             LibraryFile.active()
-            .where(LibraryFile.file_path.ilike(f"%/{search_base}.%") | LibraryFile.file_path.ilike(f"{search_base}.%"))
+            .where(_stem_matches(LibraryFile.file_path, search_base))
             .where(LibraryFile.file_path.ilike("%.3mf"))
             .order_by(LibraryFile.created_at.desc())
             .limit(3)
@@ -1028,7 +1180,21 @@ async def _resolve_3mf_fallback(archive, db: AsyncSession, base_dir):
             lib_path = Path(lib_file.file_path)
             candidate = lib_path if lib_path.is_absolute() else base_dir / lib_file.file_path
             if candidate.exists() and candidate.suffix == ".3mf":
-                logger.info("[UsageTracker] 3MF fallback: found library file %s for archive %s", candidate, archive.id)
+                conflict = _donor_3mf_conflicts(candidate, expected_plate)
+                if conflict:
+                    logger.warning(
+                        "[UsageTracker] 3MF fallback: not using library file %s for archive %s — %s",
+                        candidate,
+                        archive.id,
+                        conflict,
+                    )
+                    continue
+                logger.info(
+                    "[UsageTracker] 3MF fallback: found library file %s for archive %s (expected plate=%s)",
+                    candidate,
+                    archive.id,
+                    expected_plate,
+                )
                 return candidate
     except Exception as e:
         logger.debug("[UsageTracker] 3MF fallback: library lookup failed: %s", e)
@@ -1041,19 +1207,27 @@ async def _resolve_3mf_fallback(archive, db: AsyncSession, base_dir):
             .where(PrintArchive.printer_id == archive.printer_id)
             .where(PrintArchive.file_path != "")
             .where(PrintArchive.file_path.isnot(None))
-            .where(
-                PrintArchive.filename.ilike(f"%{search_base}.%") | PrintArchive.filename.ilike(f"{search_base}.%"),
-            )
+            .where(_stem_matches(PrintArchive.filename, search_base))
             .order_by(PrintArchive.created_at.desc())
             .limit(3)
         )
         for prev_archive in prev_result.scalars().all():
             candidate = base_dir / prev_archive.file_path
             if candidate.exists() and candidate.suffix == ".3mf":
+                conflict = _donor_3mf_conflicts(candidate, expected_plate)
+                if conflict:
+                    logger.warning(
+                        "[UsageTracker] 3MF fallback: not using archive %s's file for archive %s — %s",
+                        prev_archive.id,
+                        archive.id,
+                        conflict,
+                    )
+                    continue
                 logger.info(
-                    "[UsageTracker] 3MF fallback: found previous archive %s file for archive %s",
+                    "[UsageTracker] 3MF fallback: found previous archive %s file for archive %s (expected plate=%s)",
                     prev_archive.id,
                     archive.id,
+                    expected_plate,
                 )
                 return candidate
     except Exception as e:
@@ -1067,27 +1241,34 @@ async def _find_3mf_by_filename(
     filename: str,
     db: AsyncSession,
     base_dir,
+    print_name: str | None = None,
 ):
     """Find a 3MF file by filename from library or previous archives.
 
     Used when auto-archive is disabled and there's no archive_id, but we still
     need the 3MF slicer data for filament usage tracking.
+
+    ``print_name`` is the model name to fall back to when ``filename`` is the
+    printer's plate path, which names no model at all -- and when it is that
+    plate path, it is also what keeps a same-named file for a different plate
+    from being adopted (#2957); see :func:`_donor_3mf_conflicts`.
     """
     from pathlib import Path
 
     from backend.app.models.archive import PrintArchive
     from backend.app.models.library import LibraryFile
 
-    search_name = filename.split("/")[-1] if "/" in filename else filename
-    search_base = search_name.replace(".gcode.3mf", "").replace(".gcode", "").replace(".3mf", "")
+    search_base = _threemf_search_stem(filename, print_name)
     if not search_base:
         return None
 
+    expected_plate = _expected_plate_for_print(None, filename)
+
     # 1. Try library files matching the name
     try:
         lib_result = await db.execute(
             LibraryFile.active()
-            .where(LibraryFile.file_path.ilike(f"%/{search_base}.%") | LibraryFile.file_path.ilike(f"{search_base}.%"))
+            .where(_stem_matches(LibraryFile.file_path, search_base))
             .where(LibraryFile.file_path.ilike("%.3mf"))
             .order_by(LibraryFile.created_at.desc())
             .limit(3)
@@ -1096,6 +1277,15 @@ async def _find_3mf_by_filename(
             lib_path = Path(lib_file.file_path)
             candidate = lib_path if lib_path.is_absolute() else base_dir / lib_file.file_path
             if candidate.exists() and candidate.suffix == ".3mf":
+                conflict = _donor_3mf_conflicts(candidate, expected_plate)
+                if conflict:
+                    logger.warning(
+                        "[UsageTracker] 3MF (no-archive): not using library file %s for '%s' — %s",
+                        candidate,
+                        filename,
+                        conflict,
+                    )
+                    continue
                 logger.info("[UsageTracker] 3MF (no-archive): found library file %s for '%s'", candidate, filename)
                 return candidate
     except Exception as e:
@@ -1108,15 +1298,22 @@ async def _find_3mf_by_filename(
             .where(PrintArchive.printer_id == printer_id)
             .where(PrintArchive.file_path != "")
             .where(PrintArchive.file_path.isnot(None))
-            .where(
-                PrintArchive.filename.ilike(f"%{search_base}.%") | PrintArchive.filename.ilike(f"{search_base}.%"),
-            )
+            .where(_stem_matches(PrintArchive.filename, search_base))
             .order_by(PrintArchive.created_at.desc())
             .limit(3)
         )
         for prev_archive in prev_result.scalars().all():
             candidate = base_dir / prev_archive.file_path
             if candidate.exists() and candidate.suffix == ".3mf":
+                conflict = _donor_3mf_conflicts(candidate, expected_plate)
+                if conflict:
+                    logger.warning(
+                        "[UsageTracker] 3MF (no-archive): not using archive %s's file for '%s' — %s",
+                        prev_archive.id,
+                        filename,
+                        conflict,
+                    )
+                    continue
                 logger.info(
                     "[UsageTracker] 3MF (no-archive): found previous archive %s file for '%s'",
                     prev_archive.id,
@@ -1413,6 +1610,10 @@ async def _track_from_3mf(
                 pass  # Fall back to linear scaling
 
     results = []
+    # Trays this print drew from that no longer have an assignment to charge.
+    # Collected rather than acted on inline so one notification covers the whole
+    # print instead of one per slot (#2812).
+    unassigned_global_trays: list[int] = []
 
     for usage in filament_usage:
         slot_id = usage.get("slot_id", 0)
@@ -1639,7 +1840,19 @@ async def _track_from_3mf(
             print_started_at=print_started_at,
         )
         if spool_id is None:
-            logger.info("[UsageTracker] 3MF: no spool assignment at printer %d AMS%d-T%d", printer_id, ams_id, tray_id)
+            # WARNING, not INFO: everything upstream of this line succeeded --
+            # the 3MF was found, the grams were read, the tray resolved -- and
+            # the print will still report success while this filament is never
+            # deducted. At INFO it was invisible under the default log level and
+            # absent from the reasoning in support bundles (#2812).
+            logger.warning(
+                "[UsageTracker] 3MF: no spool assignment at printer %d AMS%d-T%d — %.1fg not deducted",
+                printer_id,
+                ams_id,
+                tray_id,
+                used_g,
+            )
+            unassigned_global_trays.append(global_tray_id)
             continue
 
         # Load spool
@@ -1749,4 +1962,11 @@ async def _track_from_3mf(
                 )
                 archive.filament_type = joined_types
 
+    if unassigned_global_trays:
+        from backend.app.services.spool_assignment_notifications import (
+            notify_missing_spool_assignments_on_print_complete,
+        )
+
+        await notify_missing_spool_assignments_on_print_complete(printer_id, unassigned_global_trays, db, logger)
+
     return results

+ 73 - 14
backend/app/services/virtual_printer/mqtt_bridge.py

@@ -23,9 +23,11 @@ Identity rewriting at cache time:
 
   - `upgrade_state.sn` (and any other nested dict's `sn` matching the real
     serial) → VP serial
-  - `net.info[*].ip` little-endian uint32 → VP bind IP. BambuStudio reads
-    this as the FTP destination IP. Without this the slicer FTPs straight
-    to the real printer and bypasses Bambuddy.
+  - `net.info[*].ip` little-endian uint32 → the address a slicer can reach
+    Bambuddy on. BambuStudio reads this as the FTP destination IP. Without
+    this the slicer FTPs straight to the real printer and bypasses Bambuddy.
+    Normally that address is the VP bind IP; `VIRTUAL_PRINTER_ADVERTISE_ADDRESS`
+    overrides it for NAT'd deployments (see `ADVERTISE_ADDRESS_ENV`).
   - `ipcam.rtsp_url` is left unchanged: BambuStudio overrides the URL host
     with the device IP it bound to (the VP), so the slicer hits the VP's
     own RTSPS proxy on port 322.
@@ -38,6 +40,7 @@ import copy
 import ipaddress
 import json
 import logging
+import os
 import socket
 from typing import TYPE_CHECKING
 
@@ -53,6 +56,24 @@ logger = logging.getLogger(__name__)
 
 REFRESH_INTERVAL_SECONDS = 30.0
 
+# Opt-in override for the address written into `net.info[].ip`. Needed only
+# where the address a slicer has to use to reach Bambuddy is not one of the
+# container's own interfaces — Docker bridge networking being the case that
+# prompted it (#2930), where the bind address is a container-private IP like
+# `172.24.0.2` and a slicer that follows it opens an FTP connection to
+# nothing. Host and macvlan networking stay the supported modes and need
+# nothing set here.
+#
+# Deliberately an environment variable rather than a change to how the VP IP
+# is resolved: the alternative was to prefer the VP's "Network Interface
+# Override" (`remote_interface_ip`), which today feeds SSDP and the cert SANs
+# only. Reading it here would silently move the FTP destination for every
+# install that has it set — the multi-NIC, VLAN and Tailscale setups, i.e.
+# exactly the ones most likely to have been tuned by hand. Unset, this
+# variable changes nothing. Mirrors `VIRTUAL_PRINTER_PASV_ADDRESS`, which
+# exists for the same reason on the FTP side.
+ADVERTISE_ADDRESS_ENV = "VIRTUAL_PRINTER_ADVERTISE_ADDRESS"
+
 # Bambuddy's internal printer state in bambu_mqtt.py (around line 2686+) is
 # updated per-field — each `if "X" in data: self.state.X = ...` block leaves
 # every other field untouched, so the state accumulates everything the
@@ -134,6 +155,32 @@ def _resolve_host_interface_for_target(target_ip: str) -> str | None:
     return ip if isinstance(ip, str) and ip else None
 
 
+def _resolve_advertise_override(vp_name: str) -> str:
+    """Return the validated `net.info[].ip` override from the environment, or "".
+
+    Validated here rather than on each refresh tick for two reasons: a typo
+    produces one warning instead of one every 30s, and an unusable value
+    falls back to the bind address instead of leaving the rewrite unarmed.
+    That second part matters — an unarmed rewrite puts the *real printer IP*
+    back in front of the slicer (#1429), so a mistyped override must not be
+    able to reopen the leak this whole path exists to close.
+    """
+    raw = os.environ.get(ADVERTISE_ADDRESS_ENV, "").strip()
+    if not raw:
+        return ""
+    try:
+        _ip_to_uint32_le(raw)
+    except ValueError:
+        logger.warning(
+            "[%s] %s=%r is not a dotted-quad IPv4 — ignoring it, using the VP bind address instead",
+            vp_name,
+            ADVERTISE_ADDRESS_ENV,
+            raw,
+        )
+        return ""
+    return raw
+
+
 def _merge_ams_dict(prev_ams: dict, new_ams: dict) -> dict:
     """Merge a new ``ams`` blob from an incremental push onto the previous one.
 
@@ -269,6 +316,10 @@ class MQTTBridge:
         # follow-up: makes silent early-returns visible without grepping the
         # source.
         self._not_armed_reason: str | None = None
+        # NAT escape hatch for `net.info[].ip`, resolved once — the process
+        # environment cannot change without a restart. "" means "use the VP
+        # bind address", which is every install that has not set it.
+        self._advertise_address = _resolve_advertise_override(vp_name)
         self._loop: asyncio.AbstractEventLoop | None = None
         self._refresh_task: asyncio.Task | None = None
         self._stopping = False
@@ -427,12 +478,15 @@ class MQTTBridge:
         sees the rewritten value (#1429). Without this sweep the sticky-key
         preservation keeps the poisoned `net.info[].ip` alive forever.
 
-        VP bind IP resolution: when `mqtt_server.bind_address` is empty or
-        `0.0.0.0` (the default for VPs that were never assigned a dedicated
-        bind IP), fall back to auto-resolving the host interface in the same
-        subnet as the printer's IP. Without this fallback, the rewrite never
-        arms on a default-config flat-LAN install and `net.info[].ip` leaks
-        the real printer IP — slicer follows it on Send (#1429 residual).
+        VP IP resolution, in order: the `VIRTUAL_PRINTER_ADVERTISE_ADDRESS`
+        override if one is set (NAT'd deployments where no local interface
+        carries the address slicers use — see `ADVERTISE_ADDRESS_ENV`), then
+        `mqtt_server.bind_address`, then — when that is empty or `0.0.0.0`,
+        the default for VPs never assigned a dedicated bind IP — the host
+        interface sharing a subnet with the printer's IP. Without that last
+        fallback the rewrite never arms on a default-config flat-LAN install
+        and `net.info[].ip` leaks the real printer IP — the slicer follows it
+        on Send (#1429 residual).
         """
 
         def _log_not_armed(reason: str) -> None:
@@ -463,14 +517,19 @@ class MQTTBridge:
             )
             return
 
-        vp_ip = getattr(self._mqtt_server, "bind_address", None)
-        vp_ip_source = "bind_address"
+        if self._advertise_address:
+            vp_ip = self._advertise_address
+            vp_ip_source = ADVERTISE_ADDRESS_ENV
+        else:
+            vp_ip = getattr(self._mqtt_server, "bind_address", None)
+            vp_ip_source = "bind_address"
         if not vp_ip or vp_ip in ("0.0.0.0", ""):  # nosec B104
             resolved = _resolve_host_interface_for_target(target_ip)
             if not resolved:
                 _log_not_armed(
                     f"no host interface shares a subnet with printer IP {target_ip} "
-                    "(and VP bind_address is 0.0.0.0/empty)"
+                    f"(and VP bind_address is 0.0.0.0/empty) — set {ADVERTISE_ADDRESS_ENV} "
+                    "to the address slicers reach Bambuddy on if this host is NAT'd"
                 )
                 return
             vp_ip = resolved
@@ -514,7 +573,7 @@ class MQTTBridge:
                 )
 
     def _rewrite_net_info_ips(self, print_state: dict) -> int:
-        """Rewrite every non-zero `net.info[].ip` in `print_state` to the VP bind IP.
+        """Rewrite every non-zero `net.info[].ip` in `print_state` to the VP's IP.
 
         Returns the number of entries rewritten. Mutates `print_state` in place.
 
@@ -595,7 +654,7 @@ class MQTTBridge:
             # stream directly from the printer. On the same LAN this works as
             # long as the slicer's stored access code matches the printer's
             # (i.e. configure the VP with the same access code as its target).
-            # Rewrite real printer IP → VP bind IP in `net.info[*].ip` so the
+            # Rewrite real printer IP → the VP's IP in `net.info[*].ip` so the
             # slicer's FTP destination resolves to the VP, not the real printer.
             self._rewrite_net_info_ips(print_data)
             # Defensive deep copy on store so the cache is fully decoupled from

+ 88 - 0
backend/app/utils/ams_drying.py

@@ -0,0 +1,88 @@
+"""Shared reading of the firmware's own AMS drying state.
+
+Kept as a leaf module on purpose. ``drying_preflight`` would be the natural
+home, but it imports ``printer_manager``, which imports ``bambu_mqtt`` — and
+``bambu_mqtt`` is one of the callers here, so putting these there would close an
+import cycle. Nothing in this module imports from the app.
+"""
+
+from collections.abc import Mapping
+from datetime import datetime, timedelta
+from typing import Any
+
+# ``dry_status`` is bits 4-7 of the per-AMS ``info`` hex string (BambuStudio
+# DevFilaSystem.cpp): 0=Off, 1=Checking, 2=Drying, 3=Cooling, 4=Stopping,
+# 5=Error, 6=HeatOutOfControl, 7=PrdTesting. Only the first three mean a cycle
+# is still live.
+#
+# 4 (Stopping) and 5 (Error) are excluded because the cycle is over or ending.
+# 6 (HeatOutOfControl) is excluded deliberately and for a different reason: an
+# AMS that has lost thermal control is exactly when a high-temperature alarm
+# should still reach the user, so it must never read as "expected heat".
+ACTIVE_DRY_STATUSES = frozenset({1, 2, 3})  # Checking, Drying, Cooling
+
+
+def is_drying_active(ams_data: Any) -> bool:
+    """True when this AMS unit reports a drying cycle in progress.
+
+    Two independent signals, because neither alone is sufficient. ``dry_time``
+    is minutes remaining and reads 0 through the cooling phase that closes a
+    cycle; ``dry_status`` covers that phase but is only present when the
+    firmware sent a parseable ``info`` field.
+    """
+    if not isinstance(ams_data, Mapping):
+        return False
+    try:
+        if int(ams_data.get("dry_time") or 0) > 0:
+            return True
+    except (TypeError, ValueError):
+        pass  # Unparseable countdown — fall through to the phase field
+    try:
+        return int(ams_data["dry_status"]) in ACTIVE_DRY_STATUSES
+    except (KeyError, TypeError, ValueError):
+        return False
+
+
+def temperature_alarm_suppressed(
+    *,
+    drying_active: bool,
+    temperature: float | None,
+    threshold: float,
+    latched_at: datetime | None,
+    now: datetime,
+    grace_minutes: int,
+) -> tuple[bool, datetime | None]:
+    """Decide whether to hold back the AMS high-temperature alarm (#1802).
+
+    Drying heats an AMS far past the alarm threshold by design — 45 C for PLA,
+    65 C for PETG, up to 85 C on an AMS-HT, against a default threshold of
+    35 C — so without this the alarm fires once an hour for the length of the
+    cycle and keeps going while the unit cools back down.
+
+    Returns ``(suppress, latched_at)``. The second element is the latch to
+    persist: a timestamp while suppression is in force, ``None`` to clear it.
+
+    Suppression is released as soon as the unit reads back at or below the
+    threshold rather than after a fixed delay, so a 65 C cycle in a cold
+    basement and a 45 C one in a warm room each get exactly the cool-down they
+    need. ``grace_minutes`` only bounds the case where the unit never returns
+    below the threshold at all — and a unit that stays that hot would have been
+    alarming with no drying involved, so releasing there restores the ordinary
+    behaviour instead of inventing a new alert.
+    """
+    if drying_active:
+        return True, now
+    if latched_at is None:
+        return False, None
+    # Back at a normal storage temperature: the cool-down is over. Note this is
+    # also the only path that can clear the latch promptly, so it is checked
+    # before the cap.
+    if temperature is not None and temperature <= threshold:
+        return False, None
+    # ``latched_at`` is never in the future: the caller either just stamped it
+    # with this ``now`` or read it back through a loader that clamps. A future
+    # stamp would make this difference negative and hold suppression for the
+    # skew on top of the cap, which is why the clamp lives at the read.
+    if now - latched_at >= timedelta(minutes=grace_minutes):
+        return False, None
+    return True, latched_at

+ 23 - 1
backend/app/utils/archive_paths.py

@@ -58,6 +58,24 @@ def _legacy_shared_photos_dir(archive: object) -> Path | None:
     return settings.base_dir / "photos"  # SEC-PATH-OK: constant subdirectory
 
 
+def _pre_recovery_photos_dir(archive: object) -> Path | None:
+    """Where this archive's photos were written while it had no 3MF.
+
+    An archive that started as a no-3MF fallback and was later filled in from a
+    3MF that turned up (#2957) changes directory: ``archive_dir`` derives from
+    ``file_path``, which goes from empty to a real path. Anything written to
+    ``<archive_dir>/<id>/photos`` before that moment is still there, so it stays
+    a lookup candidate afterwards -- the same reason
+    :func:`_legacy_shared_photos_dir` exists.
+
+    None while ``file_path`` is empty, where this *is* the current directory and
+    the caller already checks it.
+    """
+    if not (getattr(archive, "file_path", "") or ""):
+        return None
+    return settings.archive_dir / str(archive.id) / "photos"  # SEC-PATH-OK: archive.id is an int primary key
+
+
 def find_archive_photo(archive: object, filename: str) -> Path | None:
     """Locate an existing photo, or None if it is in neither location.
 
@@ -67,7 +85,11 @@ def find_archive_photo(archive: object, filename: str) -> Path | None:
     one caller is a background notification task, where an HTTP error would
     have nowhere to go.
     """
-    for directory in (archive_photos_dir(archive), _legacy_shared_photos_dir(archive)):
+    for directory in (
+        archive_photos_dir(archive),
+        _legacy_shared_photos_dir(archive),
+        _pre_recovery_photos_dir(archive),
+    ):
         if directory is None:
             continue
         try:

+ 54 - 0
backend/app/utils/color_utils.py

@@ -2,6 +2,60 @@
 
 import math
 
+# Alpha byte that means "fully opaque". Bambu's firmware reports every opaque
+# spool as RRGGBBFF, so this is the overwhelmingly common value.
+_OPAQUE_ALPHA = "FF"
+
+
+def spoolman_color_hex(rgba: str | None) -> str | None:
+    """Normalise an RRGGBB(AA) value to what Spoolman's ``color_hex`` should hold.
+
+    Eight characters only when the spool is genuinely translucent. Bambuddy used
+    to truncate to six unconditionally, which turned a clear spool's ``00000000``
+    into opaque black (#2912); passing everything through instead would rewrite
+    the ``color_hex`` of every opaque spool on its next touch, churning records in
+    people's Spoolman for no benefit. Keeping the opaque case at six characters
+    leaves existing data byte-identical.
+
+    Returns ``None`` for a missing value. A value shorter than six characters is
+    passed through unchanged so a malformed colour is not reshaped into something
+    that looks valid; a value between six and eight is truncated to six, which is
+    what the pre-#2912 behaviour did and what the six-character path still means.
+    Neither is reachable through ``_validate_rgba``, which admits only 6 or 8.
+    """
+    if not rgba:
+        return None
+    clean = rgba.strip().removeprefix("#").upper()
+    if len(clean) < 6:
+        return clean or None
+    if len(clean) >= 8 and clean[6:8] != _OPAQUE_ALPHA:
+        return clean[:8]
+    return clean[:6]
+
+
+def color_match_key(color_hex: str | None) -> str:
+    """Return the key two colours are compared on: **the shape they would be stored as**.
+
+    Deliberately the same rule as :func:`spoolman_color_hex`, so two colours match
+    exactly when storing them would produce the same value. That settles both
+    directions of the alpha question at once (#2912):
+
+    ==============  ============  ==================================================
+    value           key           consequence
+    ==============  ============  ==================================================
+    ``000000``      ``000000``    existing six-character data
+    ``000000FF``    ``000000``    still matches it — the upgrade guard, without which
+                                  the next AMS sync mints a duplicate filament for
+                                  every spool on the instance
+    ``00000000``    ``00000000``  a clear spool gets its own filament and is never
+                                  conflated with the black one, in either direction
+    ==============  ============  ==================================================
+
+    Returns ``""`` rather than ``None`` for a missing value so callers can compare
+    without guarding, which is the only reason this is not simply an alias.
+    """
+    return spoolman_color_hex(color_hex) or ""
+
 
 def colors_similar(hex_a: str, hex_b: str, threshold: int = 50) -> bool:
     """Compare two RRGGBB(AA) hex colors with tolerance for RFID/firmware variations.

+ 102 - 0
backend/app/utils/ffmpeg_output.py

@@ -0,0 +1,102 @@
+"""Turning an ffmpeg subprocess's stderr into a log line worth reading (#2968).
+
+ffmpeg opens every run with ~20 lines of version, build and library banner and
+prints its diagnosis *last*. Truncating that from the front -- ``stderr[:200]``,
+which is what most call sites did -- keeps the banner and throws the diagnosis
+away. A reporter's H2D produced twelve of these, and every one of them read
+
+    ffmpeg frame bytes capture failed (code 183): ffmpeg version 7.1.4-0+deb13u1
+    Copyright (c) 2000-2026 the FFmpeg developers  built with gcc 14 (Debian
+    14.2.0-19)  configuration: --prefix=/usr --extra-version=0+deb13u1 --toolch
+
+-- 200 characters that are identical on every install and say nothing about why
+the capture failed. The exit code was the only usable byte in the whole line.
+
+The banner-stripping summariser this module holds was written for #925 and
+lived as a private helper in ``api/routes/camera.py``, where the streaming
+endpoint used it. Ten other places log ffmpeg or ffprobe stderr -- snapshot
+capture, last-frame extraction, the layer-timelapse stitch, the archive's MP4
+conversion, external USB and RTSP capture and streaming, and timelapse
+post-processing. Seven of them truncated from the front, two logged the whole
+banner, and one already kept the tail. They all come here now, so they cannot
+drift again.
+
+Redaction is part of the summary rather than each caller's job. ffmpeg echoes
+its input URL back in the ``Input #0`` line, so a camera password or a printer
+access code reaches stderr on any failure; seven of those ten logged it
+unmasked. A helper that redacts is one that cannot be called wrong.
+
+Kept as a leaf module -- stdlib plus :mod:`core.logging_filters`, which is
+itself stdlib-only -- so the services and the route can all reach it without
+pulling a startup graph behind them.
+"""
+
+from __future__ import annotations
+
+from backend.app.core.logging_filters import redact_url_credentials
+
+# What ffmpeg prints before it has anything to say. Every line of the banner is
+# either the version line or an indented continuation, and a real diagnostic is
+# never indented this way, so the match is on the exact prefixes rather than on
+# indentation alone -- ``  Duration: ...`` and ``    Stream #0:0 ...`` are
+# indented too and are worth keeping.
+_BANNER_PREFIXES = (
+    "ffmpeg version ",
+    "ffprobe version ",
+    "  built with ",
+    "  configuration:",
+    "  libavutil ",
+    "  libavcodec ",
+    "  libavformat ",
+    "  libavdevice ",
+    "  libavfilter ",
+    "  libswscale ",
+    "  libswresample ",
+    "  libpostproc ",
+)
+
+# How much of the tail to keep. ffmpeg's diagnosis is the last thing it writes,
+# and ten lines is enough to carry the error plus the input analysis that
+# explains it without letting a chatty decoder rotate the log file.
+_MAX_LINES = 10
+
+# And a ceiling on the whole thing. Ten lines is only a bound on the log record
+# if the lines are a sane length, and ffmpeg quotes what the peer sent it back
+# at us -- a printer's RTSP response is not something Bambuddy controls. Well
+# above any real diagnosis, so this only ever trims a line that was already not
+# going to be read.
+_MAX_CHARACTERS = 2000
+
+# What to log when the summary is empty. A failure whose stderr held nothing but
+# the banner still deserves a line saying so -- ``failed: `` with an empty tail
+# reads like a truncation bug rather than a printer that closed the connection.
+NO_FFMPEG_OUTPUT = "no diagnostic output"
+
+
+def summarize_ffmpeg_stderr(text: str | bytes | None) -> str:
+    """Strip ffmpeg's boilerplate banner and keep the last lines that matter.
+
+    Accepts raw ``bytes`` as well as ``str`` and decodes with ``errors=
+    "replace"``: ffmpeg copies fragments of the stream into its error messages,
+    so a bare ``.decode()`` at the call site can raise ``UnicodeDecodeError``
+    while reporting an unrelated failure. Losing the diagnosis to a second
+    exception is the one outcome worse than logging the banner.
+
+    Returns ``""`` when there is nothing left after the banner, which is the
+    signal the streaming endpoint uses to stay quiet. One-shot callers that log
+    unconditionally should fall back to :data:`NO_FFMPEG_OUTPUT`.
+    """
+    if not text:
+        return ""
+    if isinstance(text, (bytes, bytearray)):
+        text = text.decode(errors="replace")
+    # Redaction runs on the whole string before anything is dropped: a
+    # credentialed URL that straddles the cut would otherwise leave its tail in
+    # the log with no ``@`` left for the pattern to anchor on.
+    text = redact_url_credentials(text) or ""
+    meaningful = [line for line in text.splitlines() if line.strip() and not line.startswith(_BANNER_PREFIXES)]
+    summary = "\n".join(meaningful[-_MAX_LINES:])
+    if len(summary) > _MAX_CHARACTERS:
+        # From the end, for the same reason the whole module exists.
+        summary = "..." + summary[-_MAX_CHARACTERS:]
+    return summary

+ 237 - 0
backend/app/utils/filament_types.py

@@ -15,8 +15,18 @@ which reduces a tray type to a drying-preset key. "Which drying profile?" is a
 genuinely different question with a different answer — PLA Silk dries like PLA
 but does not print like it — so folding those together would be the wrong kind
 of tidy.
+
+Two more readings of a filament type live here for the same reason. Writing one
+into an AMS slot needs the name the printer knows rather than the one on the
+spool (``printer_filament_type``), and two guards need to agree on when a value
+is a material name rather than a preset the printer can resolve
+(``is_material_name``). Both were open-coded and inconsistent before #2902.
 """
 
+import re
+
+from backend.app.utils.filament_ids import GENERIC_FILAMENT_IDS, MATERIAL_TEMPS
+
 # Types within a group are interchangeable on the printer side; Bambu Lab
 # firmware treats them as the same material. The first entry is canonical.
 #
@@ -60,3 +70,230 @@ def canonical_filament_type(ftype: str | None) -> str:
 def filament_types_compatible(a: str | None, b: str | None) -> bool:
     """Whether two filament types may stand in for one another."""
     return canonical_filament_type(a) == canonical_filament_type(b)
+
+
+# ---------------------------------------------------------------------------
+# Printer-side material names
+# ---------------------------------------------------------------------------
+
+# What a Bambu printer and the slicers accept in an AMS slot's ``tray_type``.
+# Grounded in the catalogues this repo already carries: ``filament_fields.json``
+# is the list Bambuddy itself offers when a preset is created, so every value in
+# it has to appear here -- ``TestTheMaterialsBambuddyOffers`` fails if one does
+# not. On top of that, every "Generic X" entry in
+# ``cloud._BUILTIN_FILAMENT_NAMES`` names a real type, the "Bambu X" entries add
+# the composites, and the frontend's ``parsePresetName`` list contributes
+# PEEK / PEI / PC-CF / PC-ABS.
+#
+# A filled or foamed variant is a type of its own, not a flavour of the base
+# material: PLA-AERO is a foaming PLA and PLA-GF a glass-filled one, and a slot
+# that reduces either to "PLA" invites a plain PLA plate onto filament that
+# will not print it. Four of the ones the dropdown offers were missing when
+# #2902 first landed and were being reduced exactly that way, ASA-AERO -- which
+# only the cloud catalogue names (GFB02) -- with them. See the issue thread,
+# where @doncaruana caught PLA Aero.
+#
+# Product lines, by contrast, are deliberately absent. "PLA Matte", "PETG HF"
+# and "eSUN PLA+" are things you buy, not types the firmware knows -- they
+# belong in ``tray_sub_brands``, which is where Bambu itself puts them.
+_PRINTER_TYPES: tuple[str, ...] = (
+    # Order within a length matters for ties: "PLA/PHA" must read as PLA.
+    "PLA-AERO",
+    "ASA-AERO",
+    "PAHT-CF",
+    "PA12-CF",
+    "PETG-CF",
+    "PPS-CF",
+    "PPS-GF",
+    "PPA-CF",
+    "PPA-GF",
+    "PLA-CF",
+    "PLA-GF",
+    "PA6-CF",
+    "PA6-GF",
+    "ABS-GF",
+    "ASA-CF",
+    "ASA-GF",
+    "PET-CF",
+    "PC-ABS",
+    "PA-CF",
+    "PC-CF",
+    "PP-CF",
+    "PP-GF",
+    "PE-CF",
+    "PCTG",
+    "PETG",
+    "BVOH",
+    "HIPS",
+    "PEEK",
+    "PLA",
+    "PHA",
+    "ABS",
+    "ASA",
+    "TPU",
+    "PVA",
+    "PPS",
+    "EVA",
+    "PEI",
+    "PC",
+    "PA",
+    "PP",
+    "PE",
+)
+
+# Names that are a material by another name. ``GENERIC_FILAMENT_IDS`` already
+# points NYLON and PA at the same generic id, so they are the same thing to
+# everything downstream of here.
+_TYPE_ALIASES: dict[str, str] = {
+    "NYLON": "PA",
+}
+
+_PRINTER_TYPE_SET = frozenset(_PRINTER_TYPES)
+
+# Longest first, so "PLA-CF" is recognised before the "PLA" inside it. Sorted
+# stably, so the declaration order above still decides same-length ties.
+_TYPES_LONGEST_FIRST: tuple[str, ...] = tuple(sorted((*_PRINTER_TYPES, *_TYPE_ALIASES), key=len, reverse=True))
+
+# Splits a material name into words while keeping "-" and "+" inside them:
+# "PLA-CF" is one word, "PLA/PHA" is two, and the "+" of "PLA+" stays attached
+# so the prefix rule below can see a non-letter after the type.
+_WORDS = re.compile(r"[^A-Z0-9+\-]+")
+
+
+def _word_names_type(word: str, candidate: str) -> bool:
+    """Whether one word of a material name says "this is a ``candidate``"."""
+    if word == candidate:
+        return True
+    # Two-letter types are too short to recognise inside a longer word. "PA" is
+    # nylon and "Pastel" is not, and there is no reading of the rules below that
+    # separates them -- so PA, PC, PE and PP are taken only as a word of their
+    # own. A spool whose material really is bare nylon still says so.
+    if len(candidate) < 3:
+        return False
+    # A prefix counts only when a non-letter follows it -- "PLA+" and "PETG-HS"
+    # are the type, "PLASTIC" is a word that merely starts like one.
+    if word.startswith(candidate) and not word[len(candidate)].isalpha():
+        return True
+    # A suffix needs no such guard: "HTPLA" and "rPETG" are how vendors write
+    # their own PLA and PETG, and nothing else ends in a material name.
+    return word.endswith(candidate)
+
+
+def printer_filament_type(material: str | None) -> str:
+    """Reduce a spool's material to the name an AMS slot can carry.
+
+    Bambuddy lets a spool's material be anything -- typed by hand, synced from
+    Spoolman, or picked from the colour catalogue, whose material column is the
+    vendor's product line ("PLA+", "HTPLA", "PolyTerra PLA"). Every assignment
+    path then wrote that string into the slot's ``tray_type``, and a slot whose
+    type is "PLA+" satisfies nothing that asks for PLA: not the slicer, and not
+    Bambuddy's own dispatch matcher, which compares the printer's reported
+    ``tray_type`` to the 3MF's declared type as plain equality (issue #2902).
+
+    Returns the material name unchanged when it cannot be placed. That is the
+    important half of the contract -- an unrecognised material is left exactly
+    as it arrives rather than guessed at, so this can only ever fix a slot that
+    was already wrong.
+    """
+    text = (material or "").strip()
+    if not text:
+        return ""
+
+    upper = text.upper()
+    if upper in _PRINTER_TYPE_SET:
+        return upper
+    if upper in _TYPE_ALIASES:
+        return _TYPE_ALIASES[upper]
+
+    words = [w for w in _WORDS.split(upper) if w]
+
+    # A hyphenated type written with a space is still that type. The table
+    # spells it "PLA-AERO" because that is how the preset dropdown and the
+    # slicers spell it, while a spool says "PLA Aero" and a preset name says
+    # "Bambu PLA Aero" -- and the word rules below would find only the "PLA"
+    # in those and hand back a slot that lies about what is loaded.
+    #
+    # Adjacent words only, and only when the join is a type exactly. The prefix
+    # and suffix rules are deliberately not applied across a space: "Support
+    # for PLA" would otherwise start reading as a type by its tail.
+    for first, second in zip(words, words[1:], strict=False):
+        joined = f"{first}-{second}"
+        if joined in _PRINTER_TYPE_SET:
+            return joined
+        if joined in _TYPE_ALIASES:
+            return _TYPE_ALIASES[joined]
+
+    for candidate in _TYPES_LONGEST_FIRST:
+        if any(_word_names_type(w, candidate) for w in words):
+            return _TYPE_ALIASES.get(candidate, candidate)
+
+    return text
+
+
+def nozzle_temp_range(material: str | None, tray_type: str | None) -> tuple[int, int]:
+    """The nozzle range to send with a slot, given a spool's material and the
+    type the slot will carry.
+
+    The spool's own wording leads, as it does for the filament-id lookup, so a
+    material that has its own entry keeps it. The reduced type answers for
+    everything else -- and when the reduced type is a filled or foamed variant,
+    the base material answers for that, because ``MATERIAL_TEMPS`` carries
+    eleven entries and none of them is ASA-GF. Without that last step an
+    ASA-GF spool took the 200/240 catch-all and would not have extruded;
+    "ASA" gives it ASA's 240/270 (#2902).
+    """
+    base = (tray_type or "").split("-")[0]
+    for key in (material, tray_type, base):
+        temps = MATERIAL_TEMPS.get((key or "").upper().strip())
+        if temps:
+            return temps
+    return (200, 240)
+
+
+# Both tables are keyed by material, so their keys are exactly the set of names
+# that are a material rather than a preset the printer can resolve.
+_MATERIAL_NAMES = frozenset(MATERIAL_TEMPS) | frozenset(GENERIC_FILAMENT_IDS)
+
+# "GF" + letter + digits for Bambu's own presets, "P" + hex for local and cloud
+# ones -- the shapes ``slicer_filament_resolver`` documents. Matched loosely on
+# purpose: this only ever has to be sure a value is NOT a bare material name.
+_PRESET_ID_SHAPE = re.compile(r"^(?:GF|P)[A-Za-z0-9_]*$")
+
+
+def is_material_name(value: str | None) -> bool:
+    """Whether a candidate filament id is really just a material name.
+
+    Two places ask this, and have to answer it the same way: the slicer-filament
+    resolver, which throws such a value away so its caller's generic fallback
+    can rescue the slot, and the slot-reuse check, which will not carry one
+    forward. Both compared against the table above and so saw only bare types --
+    but "PLA+" is exactly as unusable a filament id as the "PLA" they already
+    rejected, and reaching the printer is how a product line ended up in the
+    field the calibration table is keyed by (issue #2902).
+
+    A value shaped like a preset id is never a material name, whatever letters
+    it happens to end in. Reading "GFPLA" as PLA would discard it, and what the
+    user loses when that happens is the calibrated preset in the slot.
+    """
+    text = (value or "").strip()
+    if not text:
+        return False
+    if text.upper() in _MATERIAL_NAMES:
+        return True
+    if _PRESET_ID_SHAPE.match(text):
+        return False
+    reduced = printer_filament_type(text).upper()
+    if reduced in _MATERIAL_NAMES:
+        return True
+    # A filled or foamed variant is its base material by another name, and the
+    # base is what decides. Saying yes means the caller throws this value away
+    # and rescues the slot from its generic-material fallback -- so the answer
+    # has to be no when that fallback has nothing to offer, or the slot goes out
+    # with no filament id at all, which is worse than the junk it replaced.
+    # ``ABS-GF`` reduces to a generic ABS the printer can resolve; ``PPS-CF``
+    # reduces to nothing, so it is left for the caller to send as it stands.
+    #
+    # This is also what keeps a type added to the table above from silently
+    # changing the answer: "PLA-AERO" read as a material name when the table
+    # had no row for it, and it still does (#2902).
+    return reduced.split("-")[0] in _MATERIAL_NAMES

+ 76 - 0
backend/app/utils/fts_routing.py

@@ -0,0 +1,76 @@
+"""Which nozzle an AMS slot feeds, with or without a Filament Track Switch.
+
+K-profiles are per-nozzle, and the printer's calibration tables are numbered
+per-nozzle too: ``cali_idx: 16`` means "entry 16 of whichever nozzle feeds this
+tray". Without a switch that is unambiguous, because each AMS is wired to one
+extruder and says so in its ``info`` bits. With a switch installed every AMS
+reports 0xE instead and is bound to a switch *inlet*, so the answer has to come
+from the inlet binding.
+
+Every caller that resolves a slot to an extruder should go through
+``slot_extruder`` here. Three separate copies of that logic used to end in
+``else 0``, which on a switch machine silently filed every profile under the
+right-hand nozzle regardless of where the slot actually was.
+
+Kept as a leaf module with no imports of its own so the routes, the MQTT layer
+and the scheduler can all share one answer.
+"""
+
+# Which extruder each switch outlet terminates at. Measured on the maintainer's
+# H2C, 2026-08-16: Out-A is the left hotend, Out-B is the right one.
+#
+# We would rather read this than assert it, but it is not in the telemetry:
+# ``fila_switch.out`` reported ``[1, 1]`` unchanged across a 90-second capture,
+# i.e. both outlets claiming the same extruder, which cannot describe real
+# wiring. Whatever that field means, it is not outlet-to-nozzle.
+#
+# The inlet-to-outlet pairing then comes from the switch's un-crossed rest
+# position: In-A -> Out-A, In-B -> Out-B. The switch does cross the two during a
+# filament change, so this describes where a slot sits between prints, which is
+# what a manual "configure this slot" task needs. It is deliberately one table
+# to change if a machine turns up with its outlet tubes swapped.
+FTS_INLET_EXTRUDER: dict[str, int] = {
+    "A": 1,  # left / deputy
+    "B": 0,  # right / main
+}
+
+
+def extruder_for_inlet(inlet: str | None) -> int | None:
+    """Extruder fed by switch inlet ``"A"`` or ``"B"``; None for anything else."""
+    if not inlet:
+        return None
+    return FTS_INLET_EXTRUDER.get(inlet.upper())
+
+
+def slot_extruder(
+    ams_id: int,
+    tray_id: int,
+    ams_extruder_map: dict | None,
+    ams_switch_inlet: dict | None = None,
+) -> int | None:
+    """Resolve one AMS slot to the extruder it feeds, or None if unknowable.
+
+    Returns None rather than guessing. A single-nozzle printer has no map and no
+    switch, and there the caller's own default of extruder 0 is correct — but on
+    a dual-nozzle machine "I don't know" and "the right-hand nozzle" are very
+    different answers, and conflating them is what bound a left-nozzle K-profile
+    to a slot sitting on the right.
+
+    ``ams_id`` 255 is the external spool holder, where the tray id names the
+    side directly: tray 0 is Ext-L (extruder 1) and tray 1 is Ext-R (extruder 0).
+    """
+    if ams_id == 255:
+        return 1 - tray_id if tray_id in (0, 1) else None
+
+    # A real extruder id always wins. BambuStudio treats a non-0xE value as
+    # authoritative too, so an AMS wired straight to one nozzle keeps that
+    # binding even on a machine that has a switch fitted for its other units.
+    if ams_extruder_map:
+        mapped = ams_extruder_map.get(str(ams_id))
+        if mapped is not None:
+            return int(mapped)
+
+    if ams_switch_inlet:
+        return extruder_for_inlet(ams_switch_inlet.get(str(ams_id)))
+
+    return None

+ 36 - 0
backend/app/utils/http.py

@@ -1,7 +1,43 @@
 """HTTP response helpers."""
 
+from pathlib import Path
 from urllib.parse import quote
 
+from starlette.responses import PlainTextResponse
+
+
+def download_error_response(status_code: int, message: str) -> PlainTextResponse:
+    """Answer a browser-native download with a file that says what went wrong.
+
+    These URLs are reached by an ``<a download>`` click, and a browser saves
+    whatever comes back under the name it was going to use. A JSON error body
+    therefore lands on the user's disk as a .zip that will not open, with
+    nothing on screen to explain it -- the download simply appears to have
+    produced a broken file. A short text file, named for the failure rather
+    than for the download, is at least legible when opened.
+    """
+
+    return PlainTextResponse(
+        f"{message}\n",
+        status_code=status_code,
+        headers={"Content-Disposition": build_content_disposition("download-failed.txt")},
+    )
+
+
+def safe_download_filename(filename: str, fallback: str = "download", max_chars: int = 200) -> str:
+    """Return a basename safe for a bounded download response header."""
+
+    basename = Path(filename.replace("\\", "/")).name
+    cleaned = "".join("_" if ord(char) < 32 or ord(char) == 127 else char for char in basename).strip(" .")
+    if not cleaned:
+        return fallback
+    if len(cleaned) <= max_chars:
+        return cleaned
+    suffixes = "".join(Path(cleaned).suffixes)
+    suffix = suffixes if len(suffixes) <= 32 else ""
+    stem_chars = max(1, max_chars - len(suffix))
+    return f"{cleaned[:stem_chars]}{suffix}"
+
 
 def build_content_disposition(filename: str, disposition: str = "attachment") -> str:
     """Build an RFC 6266-compliant Content-Disposition header value.

+ 66 - 0
backend/app/utils/kprofile_lookup.py

@@ -0,0 +1,66 @@
+"""Resolve an AMS slot's K value from the printer's calibration table.
+
+H2-series trays carry no ``k`` field of their own — only ``cali_idx`` — so the
+K value on the AMS slot card (#2854) is looked up from the printer's
+calibration table in ``state.kprofiles``. That table is not flat: the printer
+numbers it **per nozzle**, so entry 16 exists under every nozzle it holds
+profiles for and means a different profile on each.
+
+``state.kprofiles`` is the union across nozzle diameters (see
+``BambuMQTTClient._store_kprofiles``), which is what the assign paths need but
+makes ``cali_idx`` alone ambiguous. Resolution here is therefore:
+
+1. the slot's own extruder, which separates the two nozzles of a dual-nozzle
+   machine outright;
+2. failing that, the diameters currently installed, which separates a live
+   table from one left behind by a nozzle that has since been swapped out.
+
+If both fail to single out one profile the answer is ``None``. A blank space on
+the card is a smaller error than confidently printing the other nozzle's number.
+"""
+
+from collections.abc import Callable
+
+from backend.app.utils.fts_routing import slot_extruder
+
+
+def build_slot_k_resolver(state) -> Callable[[int | None, int, int], float | None]:
+    """Return ``resolve(cali_idx, ams_id, tray_id) -> k value or None``.
+
+    Built once per serialization pass and closed over the state, so the REST
+    and WebSocket views of the same card cannot answer differently.
+    """
+    # (extruder, cali_idx) -> {nozzle_diameter: k}. The inner dict is what
+    # detects the ambiguity: more than one entry means two nozzles' tables both
+    # claim this index on this extruder.
+    table: dict[tuple[int, int], dict[str, float]] = {}
+    for kp in getattr(state, "kprofiles", None) or []:
+        if kp.slot_id is None or not kp.k_value:
+            continue
+        try:
+            k_value = float(kp.k_value)
+        except (ValueError, TypeError):
+            continue  # Skip K-profile entries with unparseable values
+        try:
+            extruder = int(kp.extruder_id or 0)
+        except (ValueError, TypeError):
+            extruder = 0
+        table.setdefault((extruder, kp.slot_id), {})[str(kp.nozzle_diameter or "")] = k_value
+
+    installed = {str(n.nozzle_diameter) for n in (getattr(state, "nozzles", None) or []) if n.nozzle_diameter}
+
+    def resolve(cali_idx: int | None, ams_id: int, tray_id: int) -> float | None:
+        if cali_idx is None:
+            return None
+        extruder = slot_extruder(ams_id, tray_id, state.ams_extruder_map, state.ams_switch_inlet)
+        # Single-nozzle printers report everything under extruder 0, and that
+        # is also the right default when the routing is simply unknown.
+        by_nozzle = table.get((extruder if extruder is not None else 0, cali_idx))
+        if not by_nozzle:
+            return None
+        if len(by_nozzle) == 1:
+            return next(iter(by_nozzle.values()))
+        live = [k for nozzle, k in by_nozzle.items() if nozzle in installed]
+        return live[0] if len(live) == 1 else None
+
+    return resolve

+ 20 - 0
backend/app/utils/natural_sort.py

@@ -0,0 +1,20 @@
+"""Natural (numeric-aware) string sorting, e.g. "Drybox 2" before "Drybox 10"."""
+
+import re
+
+_CHUNK_RE = re.compile(r"(\d+)")
+
+
+def natural_sort_key(value: str) -> tuple:
+    """Sort key that orders embedded numbers by value, not lexicographically.
+
+    A plain string sort puts "Drybox 10" before "Drybox 2" (character by
+    character, "1" < "2"). Splitting into alternating text/digit runs and
+    comparing the digit runs as integers instead gets "Drybox 2" before
+    "Drybox 10", without assuming every name follows a fixed "prefix N"
+    shape. `_CHUNK_RE.split` always yields text chunks at even indices and
+    digit chunks at odd indices for any input, so the type at a given index
+    is consistent across every key this function produces — two keys can be
+    compared without ever hitting a str-vs-int mismatch mid-tuple.
+    """
+    return tuple(int(chunk) if chunk.isdigit() else chunk.lower() for chunk in _CHUNK_RE.split(value))

+ 82 - 0
backend/app/utils/print_jobs.py

@@ -0,0 +1,82 @@
+"""Telling the printer's own internal jobs apart from a user's print.
+
+Bambu firmware runs jobs on its own behalf -- bed levelling, vibration
+compensation, the pressure-advance line it lays down before a print when flow
+dynamics calibration is on -- and reports them over MQTT through exactly the
+same print-start and print-complete events a real print uses. Nothing about the
+event says "this one is mine": Bambuddy has to recognise the job by name.
+
+Getting that wrong is not free. An unrecognised calibration run has no 3MF
+anywhere on the printer, so the archive path sweeps FTP for a file that cannot
+exist -- six candidate names across five directories, with retries -- and then
+writes a no-3MF archive named after the calibration, on a printer that is in
+the middle of calibrating.
+
+Kept as a leaf module with no imports of its own so both the print-start and
+print-complete callbacks can share one answer.
+"""
+
+# Job names the printer runs for itself. Matched exactly (after normalising),
+# not by prefix or substring: "auto" and "calib" are ordinary words in a user's
+# own filenames, and a rule loose enough to catch an unnamed future calibration
+# would silently swallow somebody's print.
+#
+# ``auto_cali_for_user`` is the bed-levelling / vibration run, normally reported
+# with a ``/usr/etc/print/`` path that the rule below catches on its own; it is
+# listed anyway because the path is not guaranteed and a name-only report of it
+# would otherwise slip through.
+#
+# ``auto_pa_line_calib_mode`` is the pressure-advance (K profile) line. This one
+# is reported as a *subtask name* with no ``/usr/`` path at all, which is why
+# the path rule alone was never enough.
+#
+# ``pa_line_calib_mode`` and ``pa_pattern_calib_mode`` are the same calibration
+# started by hand rather than automatically before a print. Manual flow dynamics
+# offers both shapes -- a line and a pattern -- and each reports under its own
+# name with no ``auto_`` prefix, so neither is covered by the automatic entry
+# above and both produced the same no-3MF archive. They are listed as two
+# literals rather than matched by a shared ``pa_`` stem for the reason the whole
+# set is exact: a stem rule would also swallow a user's own ``pa_bracket.3mf``.
+INTERNAL_JOB_NAMES = frozenset(
+    {
+        "auto_cali_for_user",
+        "auto_pa_line_calib_mode",
+        "pa_line_calib_mode",
+        "pa_pattern_calib_mode",
+    }
+)
+
+# Longest first: ``.gcode.3mf`` has to be stripped whole, or ``.3mf`` would
+# match first and leave a trailing ``.gcode`` behind.
+_PRINT_SUFFIXES = (".gcode.3mf", ".gcode", ".3mf")
+
+
+def _normalize_job_name(value: str) -> str:
+    """Reduce a reported name to something comparable against the set above.
+
+    Drops any directory part, one print-file suffix, and case. The printer is
+    not consistent about which of these it includes -- the same calibration
+    reports as a bare name in ``subtask_name`` and, when it appears at all, as
+    a full path in ``gcode_file``.
+    """
+    name = value.rsplit("/", 1)[-1].rsplit("\\", 1)[-1].strip().casefold()
+    for suffix in _PRINT_SUFFIXES:
+        if name.endswith(suffix):
+            return name[: -len(suffix)]
+    return name
+
+
+def is_internal_printer_job(filename: str | None, subtask_name: str | None = None) -> bool:
+    """True when this print event belongs to the printer, not to a user.
+
+    Both fields are tested because neither is reliably populated: the
+    pressure-advance line arrives as a subtask name with no filename, while the
+    levelling run arrives as a ``/usr/etc/print/...`` path. A job is internal if
+    *either* field says so.
+    """
+    if filename and filename.startswith("/usr/"):
+        # Bambu keeps its own calibration gcode on the read-only system
+        # partition. Nothing a user can print ever lives there, so the whole
+        # prefix is safe to treat as internal without naming each file.
+        return True
+    return any(_normalize_job_name(value) in INTERNAL_JOB_NAMES for value in (filename, subtask_name) if value)

+ 123 - 0
backend/app/utils/threemf_tools.py

@@ -1025,6 +1025,96 @@ def extract_bed_type_from_3mf(file_path: Path, plate_id: int | None = None) -> s
     return extract_plate_metadata_from_3mf(file_path, plate_id).bed_type
 
 
+# Bed temperature is not one key in a BambuStudio project. Every plate type has
+# its own per-filament array, and the plate actually fitted is named separately
+# in ``curr_bed_type`` -- so reading a bed temperature means picking the array
+# the plate points at. Keys and mapping are BambuStudio's own
+# ``get_bed_temp_1st_layer_key`` / ``get_bed_temp_key`` (PrintConfig.hpp), and
+# the plate names are the ``curr_bed_type`` enum values (PrintConfig.cpp).
+# First-layer temperature first: that is what the printer heats to before the
+# print starts, which is what preheat is trying to reach.
+#
+# ``Default Plate`` is deliberately absent -- BambuStudio maps it to no key at
+# all, so there is nothing to read and guessing a plate would invent a bed
+# temperature the slice never specified.
+_BED_TEMP_KEYS: dict[str, tuple[str, str]] = {
+    "Cool Plate": ("cool_plate_temp_initial_layer", "cool_plate_temp"),
+    "Engineering Plate": ("eng_plate_temp_initial_layer", "eng_plate_temp"),
+    "High Temp Plate": ("hot_plate_temp_initial_layer", "hot_plate_temp"),
+    "Textured PEI Plate": ("textured_plate_temp_initial_layer", "textured_plate_temp"),
+    "Supertack Plate": ("supertack_plate_temp_initial_layer", "supertack_plate_temp"),
+}
+
+# Fallback for a config that names no plate: the Orca/PrusaSlicer spelling,
+# which is a single value rather than a per-plate array.
+_GENERIC_BED_TEMP_KEYS = ("bed_temperature_initial_layer", "bed_temperature")
+
+
+def _plate_temperature(val) -> int | None:
+    """Bed temperature from one plate-temperature entry, or None.
+
+    The plate arrays carry one entry per filament in the project, and a 0 means
+    that filament cannot print on this plate. The bed only has one temperature,
+    so the print runs at the highest its filaments ask for -- taking entry 0 the
+    way the neighbouring scalar settings do would store a 0 for any project
+    whose first filament is not one this plate is heated for.
+    """
+    values = val if isinstance(val, list) else [val]
+    temps = []
+    for entry in values:
+        if isinstance(entry, bool) or not isinstance(entry, (int, float, str)):
+            continue
+        try:
+            temps.append(int(float(entry)))
+        except (TypeError, ValueError):
+            continue
+    return max(temps) if temps else None
+
+
+def bed_temperature_from_config(data: dict) -> int | None:
+    """Bed temperature for the plate *data* is sliced for, or None (#2989).
+
+    *data* is a parsed ``Metadata/project_settings.config``. Lives here rather
+    than beside the archive parser so the ingest path and the one-shot backfill
+    that repairs archives written before the fix read it exactly the same way.
+    """
+    bed_type = str(data.get("curr_bed_type") or "").strip()
+    for key in (*_BED_TEMP_KEYS.get(bed_type, ()), *_GENERIC_BED_TEMP_KEYS):
+        if key not in data:
+            continue
+        temperature = _plate_temperature(data[key])
+        # A plate array of all zeros means no filament in the project prints on
+        # this plate, which is not a bed temperature -- keep looking rather than
+        # recording a 0 that reads as "cold bed".
+        if temperature:
+            return temperature
+    return None
+
+
+def extract_bed_temperature_from_3mf(file_path: Path) -> int | None:
+    """Read a 3MF's bed temperature straight off disk, or None.
+
+    For the backfill, which has a ``file_path`` and nothing else. Opens only
+    ``Metadata/project_settings.config`` -- the archive parser reads thumbnails,
+    the model and the slice info as well, and none of that is wanted here.
+
+    Every failure is None. Deliberately broader than the handful of exceptions a
+    malformed zip is expected to raise: the caller runs inside the startup
+    migration, which has no handler above it, so anything unlisted escaping here
+    does not skip one archive -- it stops Bambuddy from booting, and keeps
+    stopping it, because the one-shot flag is written in the same transaction
+    that just rolled back. A bed temperature is not worth that.
+    """
+    try:
+        with zipfile.ZipFile(file_path, "r") as zf:
+            if "Metadata/project_settings.config" not in zf.namelist():
+                return None
+            data = json.loads(zf.read("Metadata/project_settings.config").decode())
+        return bed_temperature_from_config(data) if isinstance(data, dict) else None
+    except Exception:
+        return None
+
+
 # Header values exposed as `{placeholder}` substitutions inside snippets.
 # Aliases let users write Prusa-style names (`{max_layer_z}`) that map onto
 # Bambu/Orca header keys (`max_z_height`).
@@ -1120,6 +1210,39 @@ def default_plate_gcode_name(names: list[str]) -> str | None:
     return gcodes[0]
 
 
+def names_carry_gcode(names: list[str]) -> bool:
+    """Is this 3MF a sliced file — does it carry printer-executable G-code?
+
+    One definition, because several of them is the bug (#2993). The archive
+    side judged a file by what it holds -- the card's GCODE badge reads the
+    layer count and print time parsed out of the plate G-code, and
+    ``/archives/{id}/capabilities`` scanned the zip -- while the library judged
+    it by its filename. So a sliced 3MF stored as ``Foo.3mf`` rather than
+    ``Foo.gcode.3mf`` carried the badge and still re-imported as a source-only
+    project. This is the answer for anything asking the zip directly.
+
+    Defers to ``default_plate_gcode_name`` rather than testing for
+    ``Metadata/plate_<n>.gcode``, so a slicer that lays its output out some
+    other way is judged by the same rule everywhere.
+    """
+    return default_plate_gcode_name(names) is not None
+
+
+def carries_gcode(file_path: Path | str) -> bool:
+    """``names_carry_gcode`` for a file on disk. False for anything unreadable.
+
+    Only the zip's central directory is read — no member is decompressed — so
+    this is cheap enough to run on every ingested file.
+    """
+    try:
+        with zipfile.ZipFile(file_path, "r") as zf:
+            return names_carry_gcode(zf.namelist())
+    except (OSError, zipfile.BadZipFile):
+        # Not a zip, gone, or unreadable. Callers treat that as "no G-code
+        # visible", which is what they did before this check existed.
+        return False
+
+
 # The header block sits at the very top of the plate G-code. Read only that
 # much: a sliced plate is routinely tens of megabytes and `ZipFile.read()`
 # would inflate all of it to reach ~40 lines.

+ 55 - 0
backend/tests/conftest.py

@@ -167,6 +167,33 @@ def reset_auth_enabled_cache():
     invalidate_auth_enabled_cache()
 
 
+@pytest.fixture(autouse=True)
+def disconnect_printers_registered_during_a_test():
+    """Give every test an empty ``printer_manager`` singleton.
+
+    ``POST /api/v1/printers`` really calls ``connect_printer``, so a test that
+    creates a printer through the API parks a live client in the singleton --
+    and the singleton outlives the per-test in-memory database. The next test
+    on the same xdist worker gets a fresh database whose first printer is handed
+    the same primary key, and reads that leftover client as its own live status.
+    ``test_scheduled_drying_routes`` saw exactly that: an "online" printer with
+    no firmware version, so scheduling a dry came back 400 instead of 200.
+
+    Snapshotting the ids at test entry was insufficient: a client leaked by a
+    previous module became part of that snapshot and therefore survived every
+    later cleanup on the same xdist worker. Clear both before and after each
+    test. ``disconnect_printer`` also clears model/printer-info caches and stops
+    any paho thread owned by the leaked client.
+    """
+    from backend.app.services.printer_manager import printer_manager
+
+    for printer_id in list(printer_manager._clients):
+        printer_manager.disconnect_printer(printer_id)
+    yield
+    for printer_id in list(printer_manager._clients):
+        printer_manager.disconnect_printer(printer_id)
+
+
 @pytest.fixture(scope="session")
 def event_loop():
     """Create an instance of the default event loop for each test session."""
@@ -208,6 +235,7 @@ async def test_engine():
         printer,
         project,
         project_bom,
+        scheduled_drying,
         settings,
         slot_preset,
         smart_plug,
@@ -216,6 +244,7 @@ async def test_engine():
         spool,
         spool_assignment,
         spool_catalog,
+        spool_filament_preset,
         spool_k_profile,
         spool_usage_history,
         spoolbuddy_device,
@@ -560,6 +589,32 @@ def printer_factory(db_session):
     return _create_printer
 
 
+@pytest.fixture
+def location_factory(db_session):
+    _counter = [0]
+
+    async def _create_location(**kwargs):
+        from backend.app.models.location import Location
+
+        _counter[0] += 1
+        counter = _counter[0]
+
+        name = kwargs.pop("name", f"Test Location {counter}")
+        defaults = {
+            "name": name,
+            "name_key": name.strip().lower(),
+        }
+        defaults.update(kwargs)
+
+        location = Location(**defaults)
+        db_session.add(location)
+        await db_session.commit()
+        await db_session.refresh(location)
+        return location
+
+    return _create_location
+
+
 @pytest.fixture
 def notification_provider_factory(db_session):
     """Factory to create test notification providers."""

+ 136 - 0
backend/tests/integration/test_ams_drying_latch_persistence.py

@@ -0,0 +1,136 @@
+"""The AMS drying latch has to survive a backend restart (#1802).
+
+Suppression of the high-temperature alarm spans a drying cycle plus the
+cool-down after it, which together can run well over twelve hours. Holding that
+purely in memory — as the sibling ``_ams_alarm_cooldown`` dict does — meant any
+restart partway through resumed alarming about heat the user asked for, so the
+latch is stored in the settings table instead.
+"""
+
+import json
+from datetime import datetime, timedelta, timezone
+
+import pytest
+from sqlalchemy import select
+
+from backend.app.main import (
+    AMS_DRYING_GRACE_MINUTES,
+    AMS_DRYING_LATCH_KEY,
+    _load_ams_drying_latch,
+    _save_ams_drying_latch,
+)
+from backend.app.models.settings import Settings
+
+
+async def _stored_value(db_session) -> str | None:
+    result = await db_session.execute(select(Settings).where(Settings.key == AMS_DRYING_LATCH_KEY))
+    setting = result.scalar_one_or_none()
+    return setting.value if setting else None
+
+
+@pytest.mark.asyncio
+class TestAmsDryingLatchPersistence:
+    async def test_round_trip_survives_a_reload(self, db_session):
+        stamp = datetime.now(timezone.utc) - timedelta(minutes=10)
+        await _save_ams_drying_latch(db_session, {"1:0": stamp})
+        await db_session.commit()
+
+        # A fresh load is what a restarted backend does on its first pass.
+        assert await _load_ams_drying_latch(db_session) == {"1:0": stamp}
+
+    async def test_no_row_created_when_nothing_ever_dries(self, db_session):
+        await _save_ams_drying_latch(db_session, {})
+        await db_session.commit()
+        assert await _stored_value(db_session) is None
+        assert await _load_ams_drying_latch(db_session) == {}
+
+    async def test_existing_row_is_updated_not_duplicated(self, db_session):
+        first = datetime.now(timezone.utc) - timedelta(minutes=30)
+        second = datetime.now(timezone.utc)
+        await _save_ams_drying_latch(db_session, {"1:0": first})
+        await db_session.commit()
+        await _save_ams_drying_latch(db_session, {"1:0": second})
+        await db_session.commit()
+
+        result = await db_session.execute(select(Settings).where(Settings.key == AMS_DRYING_LATCH_KEY))
+        assert len(result.scalars().all()) == 1
+        assert await _load_ams_drying_latch(db_session) == {"1:0": second}
+
+    async def test_clearing_the_latch_empties_the_row(self, db_session):
+        await _save_ams_drying_latch(db_session, {"1:0": datetime.now(timezone.utc)})
+        await db_session.commit()
+        await _save_ams_drying_latch(db_session, {})
+        await db_session.commit()
+
+        assert await _stored_value(db_session) == "{}"
+        assert await _load_ams_drying_latch(db_session) == {}
+
+    async def test_multiple_units_are_tracked_independently(self, db_session):
+        now = datetime.now(timezone.utc)
+        latch = {"1:0": now - timedelta(minutes=5), "1:1": now, "2:128": now - timedelta(minutes=15)}
+        await _save_ams_drying_latch(db_session, latch)
+        await db_session.commit()
+        assert await _load_ams_drying_latch(db_session) == latch
+
+    async def test_entries_past_the_grace_cap_are_dropped_on_load(self, db_session):
+        now = datetime.now(timezone.utc)
+        fresh = now - timedelta(minutes=5)
+        stale = now - timedelta(minutes=AMS_DRYING_GRACE_MINUTES + 30)
+        await _save_ams_drying_latch(db_session, {"1:0": fresh, "9:3": stale})
+        await db_session.commit()
+
+        # The stale one would expire on its next visit anyway; dropping it here
+        # keeps rows for deleted printers from accumulating forever.
+        assert await _load_ams_drying_latch(db_session) == {"1:0": fresh}
+
+    async def test_wildly_future_stamps_are_dropped(self, db_session):
+        # A box whose clock jumps backwards (a Pi coming up before NTP) would
+        # otherwise hold the alarm suppressed until real time caught up.
+        future = datetime.now(timezone.utc) + timedelta(hours=6)
+        await _save_ams_drying_latch(db_session, {"1:0": future})
+        await db_session.commit()
+        assert await _load_ams_drying_latch(db_session) == {}
+
+    async def test_near_future_stamps_are_clamped_to_now(self, db_session):
+        # Small backwards skew survives as a latch, but must not sit ahead of
+        # now: suppression is measured as now minus the stamp, so a future one
+        # would run for the skew on top of the cap instead of the cap alone.
+        before = datetime.now(timezone.utc)
+        await _save_ams_drying_latch(db_session, {"1:0": before + timedelta(minutes=30)})
+        await db_session.commit()
+
+        loaded = await _load_ams_drying_latch(db_session)
+        assert set(loaded) == {"1:0"}
+        assert before <= loaded["1:0"] <= datetime.now(timezone.utc)
+
+    async def test_corrupt_row_reads_as_no_latch(self, db_session):
+        db_session.add(Settings(key=AMS_DRYING_LATCH_KEY, value="{not json"))
+        await db_session.commit()
+        # Degrades to the pre-#1802 behaviour rather than crashing the recorder.
+        assert await _load_ams_drying_latch(db_session) == {}
+
+    async def test_non_object_json_reads_as_no_latch(self, db_session):
+        db_session.add(Settings(key=AMS_DRYING_LATCH_KEY, value="[1, 2, 3]"))
+        await db_session.commit()
+        assert await _load_ams_drying_latch(db_session) == {}
+
+    async def test_unparseable_stamps_are_skipped_individually(self, db_session):
+        good = datetime.now(timezone.utc) - timedelta(minutes=3)
+        db_session.add(
+            Settings(
+                key=AMS_DRYING_LATCH_KEY,
+                value=json.dumps({"1:0": good.isoformat(), "1:1": "yesterday"}),
+            )
+        )
+        await db_session.commit()
+        assert await _load_ams_drying_latch(db_session) == {"1:0": good}
+
+    async def test_naive_stamps_are_read_as_utc(self, db_session):
+        # SQLite hands back naive datetimes elsewhere in the app, so a hand-edited
+        # or migrated value without an offset must not raise on comparison.
+        naive = (datetime.now(timezone.utc) - timedelta(minutes=7)).replace(tzinfo=None)
+        db_session.add(Settings(key=AMS_DRYING_LATCH_KEY, value=json.dumps({"1:0": naive.isoformat()})))
+        await db_session.commit()
+
+        loaded = await _load_ams_drying_latch(db_session)
+        assert loaded == {"1:0": naive.replace(tzinfo=timezone.utc)}

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.