Просмотр исходного кода

Merge pull request #3518 from maziggy/1.3.1

**Bambuddy 1.3.1**

**What this is**

A feature and fix release on top of 1.3.0: five new features, four changes and 22 fixes. Four community PRs went in, and 17 people are credited.

The headline is **Bambu Lab H3C support**: its nine-slot **AMS 3H** with the dryer zone, the nine-PrintTool **SwapTek** rack and the per-PrintTool external inputs, with load, unload, drying, queue dispatch and maintenance. The other headline is a fix for SQLite installs: closing a camera view could leave **a CPU core at 100%** and make pages such as Projects and Spool Inventory very slow until a restart. This came from a bug in aiosqlite, which Bambuddy now works around for every database connection.

Also new: WLED lights that show each printer's status, several File Manager files queued as separate jobs in one go, temperatures and controls on Cam Wall tiles, and each H2C nozzle's print time. "Any model" jobs now go to the printer with the fewest print hours.

New tables and columns are added automatically on both SQLite and PostgreSQL. There are no API changes.

If you are coming from 1.2.5.7 or earlier, read the 1.3.0 release notes first; all of their upgrade callouts apply to you as well.

**Before you update**

- **Update the slicer sidecar image** for the H3C presets: the Bambu Studio sidecar now ships Bambu Studio 02.09.01.70 with the H3C printer presets, and the OrcaSlicer sidecar ships OrcaSlicer 2.5.0. Older sidecar images keep working.

- **Update the SpoolBuddy daemon** on the kiosk (**Settings > Updates**) for the tag write fixes (#3076, #3500, #3504, #3505). Tags written with an earlier version can't be read by phones or other OpenTag3D readers (Bambuddy reads them fine); write them again to fix that.

- **"Any model" jobs go to the printer with the fewest print hours (#3480).** The queue used to pick the first free printer of the model in its list; it now picks the one with the fewest print hours on its Maintenance counter. On a farm, jobs will land on different printers than before.

- **Printer error notifications have new titles (#3486).** USB, network and file errors now read "System Error" instead of "Nozzle/Extruder Error", camera faults "Camera Error", and an A1 mini's AMS Lite runouts "AMS Lite Error". Filters or automations that match on the old titles need updating.

- **A Windows backup restored on Linux or macOS before this release** still shows no thumbnails and can't reprint (#3501). Restore the backup again after updating.

---

**How to update**

**Docker**

```bash
docker compose pull
docker compose up -d
```

**Native install - recommended path**

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

**Native install - manual path**

```bash
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.lock
cd frontend && sudo npm i
sudo systemctl start bambuddy
```

**Windows install**

Download `bambuddy-1.3.1-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**

**Printers and AMS:**

- **Bambu Lab H3C support** (#3442, requested by @Mx772, contributed by @daltonclift in #3490) - the H3C's nine-slot AMS 3H, laid out like the unit with a dryer box around its drying zone, the nine-position PrintTool rack and the per-PrintTool external inputs (**Ext-n**). Load and unload work from Bambuddy; an unload ends with pulling the filament back by hand, since the AMS 3H can't rewind, and the card says so. Drying targets the AMS 3H's drying zone, chamber heating switches the airduct to heating mode first, and queued prints are sent with the slot numbering Bambu Studio uses. The H3C gets its own firmware check, its 14 maintenance tasks with links to Bambu's guides, and its fault texts. Tested on an H3C with firmware 01.01.03.00.
- **The H2C shows how long each nozzle has printed** (#1908, contributed by @Keybored02 in #3507) - hovering over a nozzle on the printer card, in the nozzle rack or on the left and right hotend shows its **Print time** next to **Wear**, on firmware that reports it.

**Queue and File Manager:**

- **Queue several selected files as separate jobs** (#3462, requested and contributed by @Eifel-Joe in #3471) - the print dialog for several selected files offers **Alternatives** (one job, printed by the first free printer that fits) or **Separate jobs** (one queue item per file, in the order of the list). Two files sliced for the same printer model start on Separate jobs.

**Camera and lights:**

- **Cam Wall tiles show temperatures and finish time, and can pause, stop and switch the light** (#3479, sponsor vote Q4 2026) - with the **Full** status overlay, each tile shows nozzle, bed and chamber temperatures and a printing tile its finish time. Hovering shows **Pause/Resume**, **Stop** and a chamber light switch, for users with the `printers:control` permission.
- **WLED lights show the printer's status** (#1528, requested and contributed by @d3nn3s08 in #3124) - under **Settings > WLED**, assign a WLED preset to each printer state: Idle, Preparing, Printing, Paused, Finished, Failed / Error, Awaiting plate clear, Filament problem, HMS error and Offline. Bambuddy only switches presets; colours, effects and segments stay in WLED.

**Changed**

- **"Any model" jobs go to the printer with the fewest print hours** (#3480, sponsor vote Q4 2026) - so jobs and wear spread across a farm. Preferred colours still come first. See **Before you update**.
- **Profile defaults is compact and has Apply all** - **Settings > Filament > Profile defaults** shows one line per default, sorted by product, with a search box. **Apply all** applies every default to the spools in stock, filling in only what a spool doesn't have yet.
- **Slicer sidecars on Bambu Studio 02.09.01.70 and OrcaSlicer 2.5.0** (#3442) - the Bambu Studio sidecar ships the H3C printer presets. OrcaSlicer has no H3C presets yet.
- **SpoolBuddy writes OpenTag3D 2.004 on NTAG215 and NTAG216 tags** (#3500, suggested by @pleasantone) - with material, brand, colour name, up to four colours, print temperatures, net weight and empty spool weight. NTAG213 tags are too small and keep the first version.

**Fixes**

**H3C:**

- After a tool swap, the H3C's printer card could show the temperature of the hotend that just went back to the rack instead of the one printing (#3442, reported by @hurr1k4ne, cause found by @daltonclift). The card now shows the hotend on the toolhead, as Bambu Studio does, and names it by its rack position (**Nozzle 5**).
- A nozzle's hover card showed a hotend without RFID in the rack as **Mounted** and the one on the toolhead as **Docked** (#3442, frame from @daltonclift). It now says **Mounted** for the nozzles on the toolhead.
- A printer named "H3C" turned every "H3C" in the support package into [PRINTER], the model included (#3442, reported by @daltonclift). A name that is only a model name now stays.

**Database and camera:**

- Closing a camera view could leave a CPU core at 100% on SQLite, and other pages became very slow until a restart (#3515, reported by @BrainDeLook). Bambuddy now works around the aiosqlite bug behind it (omnilib/aiosqlite#403) for every database connection. PostgreSQL was not affected.
- The floating camera window opened about twenty camera streams when it was open while the page loaded, and they filled the browser's connections to the server (#3431, reported by @buffdriver). Camera views now open one stream.

**Printers, AMS and drying:**

- On an A1 or A1 mini, the external spool showed as empty after a colour or preset change in Configure Slot, until the printer's next full update, and the preset check reported it as not confirmed (#3513, reported by @SaadShaya7).
- An A2L with an AMS 2 Pro and an AMS Lite attached at the same time showed only the external spool (#3499, reported by @auberonkai). Both units now show. The connection diagnostic no longer fails an A2L on "Store sent files on external storage", which the A2L doesn't offer.
- A printer prompt answered on the printer, such as the A1 mini's "is the filament extruded?", stayed in the HMS list until **Clear errors** was clicked (#3487).
- A printer whose file service hung during a print was asked for its timelapse folder again and again, until an H2S refused every upload with "421 There are too many connections" (#3474, reported by @F9mm). The checks now back off and stop, and a printer answering 421 gets its own message in the queue.
- Auto-drying never dried an AMS HT again after it accepted a drying command without heating, while the humidity alarms kept firing (#3478, reported by @superflyer11). Such a stuck timer now counts as not drying; after two in a row, auto-drying stops on that unit and sends **Auto-Drying Suspended**, saying the timer stood still (new template **Auto-Drying Not Heating**).

**Queue, archives and usage:**

- A queue job whose print the printer resumed, for example after a power cut or a Bambuddy restart, stayed "printing" and blocked the printer's queue (#3485, reported by @SaadShaya7). A print found paused when Bambuddy starts now carries on as the same print instead of a second archive.
- A print that failed or was cancelled before its first layer could charge a whole spool, when the AMS read 0% for a moment while unloading (#3511). Such a print now charges nothing.
- Failed and cancelled prints didn't get their timelapse (#3481). It is now fetched and attached like any other.
- The log didn't say which auto-archive switch skipped a print (#3477, reported by @Fuzzylog2x). It now names the global switch or the printer, and the switch's description says what off means.

**Notifications and HMS:**

- Every restart sent printer errors again that the printer keeps in its list long after they happened, and several kinds of fault had the wrong title (#3486, reported by @SaadShaya7). Notified faults are now stored. See **Before you update** for the new titles.
- The fix above logged "no such table: hms_notified_faults" and still sent the errors again, because the table was never created (#3502, reported by @pleasantone). It is created on the next start.

**SpoolBuddy:**

- The kiosk's **Write** page sent the write to the first registered device, which could be another kiosk or an old reader long offline, and every kiosk showed every reader's weight and tags (#3504, reported by @pleasantone). The kiosk now works with its own device.
- With Bambuddy's own inventory, a tag written to a new spool stayed on the old spool too (#3505, reported by @pleasantone). It now moves.
- **Write Tag** reported success for every write (#3076, reported by @jinjiangz, gaps found by @pleasantone). SpoolBuddy now reads the data back, refuses a tag too small or not NDEF-formatted, and no longer runs a write twice.
- Tags written by SpoolBuddy showed as empty on phones and other OpenTag3D readers (#3500, reported by @pleasantone). The data now starts with the spool record. Tags written before need writing again.

**Slicing and backup:**

- A 3MF sliced in Bambuddy with a system process preset opened in Bambu Studio with the preset's own values, and a re-slice there lost the settings changed on top of it (#3497, reported by @mdbook).
- A backup from Windows restored on Linux or macOS showed no thumbnails and refused 3D preview and reprint (#3501, reported by @Tech0verlord). The restore now converts the stored paths.

**Merged community PRs in this release**

- #3124 by @d3nn3s08 - WLED lights show the printer's status.
- #3471 by @Eifel-Joe - queue several selected files as separate jobs.
- #3490 by @daltonclift - Bambu Lab H3C support.
- #3507 by @Keybored02 - each H2C nozzle's print time.

**Thanks**

To everyone who requested, reported, captured logs, or contributed: @auberonkai, @BrainDeLook, @buffdriver, @d3nn3s08, @daltonclift, @Eifel-Joe, @F9mm, @Fuzzylog2x, @hurr1k4ne, @jinjiangz, @Keybored02, @mdbook, @Mx772, @pleasantone, @SaadShaya7, @superflyer11, @Tech0verlord.

---
**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 6 часов назад
Родитель
Сommit
c770493ee4
100 измененных файлов с 9318 добавлено и 633 удалено
  1. 0 16
      .basedpyright/baseline.json
  2. 1 0
      .gitignore
  3. 39 0
      CHANGELOG.md
  4. 5 0
      backend/app/api/routes/camera.py
  5. 2 1
      backend/app/api/routes/inventory.py
  6. 25 0
      backend/app/api/routes/library.py
  7. 192 4
      backend/app/api/routes/maintenance.py
  8. 1 0
      backend/app/api/routes/notification_templates.py
  9. 142 18
      backend/app/api/routes/printers.py
  10. 20 2
      backend/app/api/routes/settings.py
  11. 15 2
      backend/app/api/routes/slicer_presets.py
  12. 24 2
      backend/app/api/routes/spoolbuddy.py
  13. 7 2
      backend/app/api/routes/spoolman.py
  14. 15 2
      backend/app/api/routes/spoolman_inventory.py
  15. 42 34
      backend/app/core/auth.py
  16. 1 1
      backend/app/core/config.py
  17. 164 32
      backend/app/core/database.py
  18. 3 0
      backend/app/core/sqlite_fk.py
  19. 610 2
      backend/app/data/hms_catalog.json
  20. 145 59
      backend/app/main.py
  21. 29 0
      backend/app/models/hms_notified_fault.py
  22. 11 0
      backend/app/models/notification_template.py
  23. 3 1
      backend/app/models/printer.py
  24. 5 0
      backend/app/models/spool_assignment.py
  25. 10 9
      backend/app/models/spoolman_slot_assignment.py
  26. 19 0
      backend/app/schemas/notification_template.py
  27. 92 2
      backend/app/schemas/printer.py
  28. 1 0
      backend/app/schemas/spool.py
  29. 22 7
      backend/app/schemas/spoolman.py
  30. 10 4
      backend/app/services/archive_cost_estimate.py
  31. 65 0
      backend/app/services/backup_paths.py
  32. 443 140
      backend/app/services/bambu_mqtt.py
  33. 6 5
      backend/app/services/camera.py
  34. 10 3
      backend/app/services/camera_light.py
  35. 69 0
      backend/app/services/design_settings.py
  36. 8 26
      backend/app/services/filament_deficit.py
  37. 15 0
      backend/app/services/firmware_check.py
  38. 18 0
      backend/app/services/hms_errors.py
  39. 58 0
      backend/app/services/hms_notified.py
  40. 5 4
      backend/app/services/kprofile_drift.py
  41. 29 3
      backend/app/services/log_reader.py
  42. 11 3
      backend/app/services/notification_service.py
  43. 99 6
      backend/app/services/opentag3d.py
  44. 9 9
      backend/app/services/print_cost_estimate.py
  45. 134 32
      backend/app/services/print_scheduler.py
  46. 84 25
      backend/app/services/printer_manager.py
  47. 4 2
      backend/app/services/slot_nozzle.py
  48. 40 44
      backend/app/services/spool_assignment_notifications.py
  49. 2 1
      backend/app/services/spool_relocation.py
  50. 37 36
      backend/app/services/spoolman_tracking.py
  51. 49 58
      backend/app/services/usage_tracker.py
  52. 29 6
      backend/app/services/virtual_printer/manager.py
  53. 396 0
      backend/app/services/wled.py
  54. 61 1
      backend/app/utils/printer_models.py
  55. 390 0
      backend/app/utils/tray_ids.py
  56. 6 2
      backend/tests/integration/test_git_backup_runs.py
  57. 20 0
      backend/tests/integration/test_print_complete_end_to_end.py
  58. 61 0
      backend/tests/integration/test_printer_scope_1727.py
  59. 245 0
      backend/tests/integration/test_printers_api.py
  60. 61 0
      backend/tests/integration/test_restore_windows_paths_3501.py
  61. 14 0
      backend/tests/integration/test_settings_api.py
  62. 73 6
      backend/tests/integration/test_spoolbuddy.py
  63. 17 0
      backend/tests/integration/test_spoolman_slot_requests.py
  64. 66 0
      backend/tests/integration/test_startup_creates_every_table.py
  65. 8 0
      backend/tests/integration/test_timelapse_editor_job.py
  66. 9 0
      backend/tests/integration/test_virtual_printer_api.py
  67. 5 0
      backend/tests/pytest.ini
  68. 47 0
      backend/tests/unit/services/test_bambu_mqtt.py
  69. 33 0
      backend/tests/unit/services/test_filament_low_producer_2913.py
  70. 97 0
      backend/tests/unit/services/test_resume_after_startup_pause_3485.py
  71. 77 0
      backend/tests/unit/services/test_vp_external_spool_mapping_3237.py
  72. 860 0
      backend/tests/unit/services/test_wled.py
  73. 121 0
      backend/tests/unit/test_a2l_mixed_ams_3499.py
  74. 233 0
      backend/tests/unit/test_auto_drying_parked_3478.py
  75. 229 0
      backend/tests/unit/test_cancelled_sqlite_close_3515.py
  76. 57 1
      backend/tests/unit/test_completion_queue_item_match.py
  77. 86 0
      backend/tests/unit/test_external_slot_partial_push_3513.py
  78. 134 0
      backend/tests/unit/test_failed_print_bogus_remain_3511.py
  79. 1 0
      backend/tests/unit/test_forget_printer.py
  80. 782 0
      backend/tests/unit/test_h3c_ams3h.py
  81. 26 0
      backend/tests/unit/test_h3c_hms_texts.py
  82. 275 0
      backend/tests/unit/test_h3c_maintenance_firmware_usage.py
  83. 104 0
      backend/tests/unit/test_h3c_mounted_hotend_temp_3442.py
  84. 108 0
      backend/tests/unit/test_h3c_queue_tray_ids_migration.py
  85. 156 0
      backend/tests/unit/test_hms_notified_restart_3486.py
  86. 85 0
      backend/tests/unit/test_nozzle_print_time_1908.py
  87. 52 19
      backend/tests/unit/test_opentag3d.py
  88. 115 0
      backend/tests/unit/test_opentag3d_v2_3500.py
  89. 2 0
      backend/tests/unit/test_outbound_url_ssrf_guards.py
  90. 62 0
      backend/tests/unit/test_printer_models.py
  91. 43 1
      backend/tests/unit/test_printer_schema.py
  92. 288 0
      backend/tests/unit/test_process_changes_recorded_3497.py
  93. 218 0
      backend/tests/unit/test_scheduler_wear_order_3480.py
  94. 195 0
      backend/tests/unit/test_spoolman_slot_rack_ams_migration.py
  95. 36 0
      backend/tests/unit/test_support_model_name_redaction_3442.py
  96. 291 0
      backend/tests/unit/test_tray_ids.py
  97. 116 0
      backend/tests/unit/test_windows_backup_paths_3501.py
  98. 30 0
      backend/tests/unit/test_wled_config_migration.py
  99. 48 0
      backend/tests/unit/test_wled_startup.py
  100. BIN
      frontend/public/img/printers/h3c.png

+ 0 - 16
.basedpyright/baseline.json

@@ -3859,22 +3859,6 @@
                     "lineCount": 1
                 }
             },
-            {
-                "code": "reportArgumentType",
-                "range": {
-                    "startColumn": 32,
-                    "endColumn": 65,
-                    "lineCount": 1
-                }
-            },
-            {
-                "code": "reportArgumentType",
-                "range": {
-                    "startColumn": 32,
-                    "endColumn": 65,
-                    "lineCount": 1
-                }
-            },
             {
                 "code": "reportArgumentType",
                 "range": {

+ 1 - 0
.gitignore

@@ -52,6 +52,7 @@ firmware/
 
 # Screenshots (development - root folder only)
 /screenshots/
+output/playwright/
 
 # Logs
 *.log

+ 39 - 0
CHANGELOG.md

@@ -2,6 +2,45 @@
 
 All notable changes to Bambuddy will be documented in this file.
 
+## [1.3.1] - Unreleased
+
+### Added
+- **Bambu Lab H3C support (#3442, requested by @Mx772, contributed by @daltonclift in #3490)** — Bambuddy now supports the H3C: its nine-slot AMS 3H, the nine-PrintTool SwapTek rack and the per-PrintTool external inputs. The printer card lays out the AMS 3H like the unit, with a dryer box around the slots of its drying zone and the ambient readings in the header, shows the nine-position PrintTool rack, and names the external inputs **Ext-n**. Load and unload work from Bambuddy; an unload ends with pulling the filament back by hand, since the AMS 3H can't rewind, and the card says so. A load into a slot that reports no filament is refused. Drying targets the AMS 3H's drying zone, and chamber heating switches the airduct to heating mode first, because the H3C's chamber heater only heats in that mode. Queued prints are sent with the slot numbering Bambu Studio uses, and the slot numbering of every other printer is unchanged. A stopped print's filament usage is scaled by layers, since the H3C's progress includes its start sequence. The H3C gets its own firmware check, its 14 maintenance tasks with links to Bambu's guides (listed only when an H3C is added) and its fault texts. Queue jobs that a daily build stored for an H3C with the old slot numbers are corrected once on start. Tested on an H3C with firmware 01.01.03.00.
+- **WLED lights show the printer's status (#1528, requested and contributed by @d3nn3s08 in #3124)** — A WLED controller can now show what each printer is doing. Under **Settings > WLED**, pick a printer, enter its WLED address and assign one of the presets saved in WLED to each state: Idle, Preparing, Printing, Paused, Finished, Failed / Error, Awaiting plate clear, Filament problem, HMS error and Offline. Colours, effects and segments stay in WLED; Bambuddy only switches presets. A state without a preset falls back to the next one that has one (a filament runout without its own preset shows Paused). **Awaiting plate clear** applies only while the queue's **Require plate clear** setting holds the printer, and **Filament problem** only when the printer paused because the filament ran out. An optional timeout switches Finished back to Idle. The page can test the connection, load the presets from WLED and try each preset on the strip. If WLED is unreachable, Bambuddy retries every 30 seconds and logs the outage once. Several printers can share one controller when each preset is saved with "Checked segments only". The tab needs the `printers:update` permission.
+- **Queue several selected files as separate jobs (#3462, requested and contributed by @Eifel-Joe in #3471)** — Selecting several sliced files in the File Manager and pressing **Print** always made them printer alternatives: one queue job, run by the first free printer of a matching model. Two files sliced for the same printer model were refused, so putting several parts into one printer's queue meant printing each file on its own. The print dialog now offers **Alternatives** or **Separate jobs**. Separate jobs puts one queue item per file into the queue, in the order of the list, with the printers (or Any [model]), plate per file, print options, schedule and quantity applying to every item; each item gets its filament mapping at dispatch, against its own file. The dialog picks what fits: two files for the same model start on Separate jobs, with Alternatives unavailable; files for different models start on Alternatives, as before. A version group is still printed as alternatives only. If the queue refuses a file, the files before it stay queued and a second **Print**, also after closing and reopening the dialog, continues with the rest without queueing anything twice. The toolbar button now reads **Print (N files)**. Wiki updated.
+- **Cam Wall tiles show temperatures and finish time, and can pause, stop and switch the light (#3479, sponsor vote Q4 2026)** — With the **Full** status overlay, every connected tile shows its nozzle (both on a dual-nozzle printer), bed and chamber temperatures, and a printing tile adds its finish time beside the time left. Hovering over a tile shows **Pause/Resume** and **Stop**, with the same confirmations as the printer card, and a **chamber light** switch; **Tile controls: Always** in the wall settings keeps them on screen, and a touch screen always shows them. The controls need the `printers:control` permission; a user without it, an offline printer and a Cam Wall token (kiosk) wall get none.
+- **The H2C shows how long each nozzle has printed (#1908, contributed by @Keybored02 in #3507)** — Recent H2C firmware counts the print time of every nozzle, which says more about a nozzle's use than the wear estimate. Hovering over a nozzle on the printer card, in the nozzle rack or on the left and right hotend, now shows its **Print time** next to **Wear**. Firmware that does not report it, and other printers, show no print time. Wiki updated.
+
+### Changed
+- **SpoolBuddy writes OpenTag3D 2.004 on NTAG215 and NTAG216 tags (#3500, suggested by @pleasantone)** — SpoolBuddy wrote the first version of the OpenTag3D layout. The current version moved every field, so a reader that only knows it read the brand, colour, weight and temperatures from the wrong places. Tags with room for it now get version 2.004, with material, brand, colour name, up to four colours, minimum and maximum print temperature, net weight and empty spool weight. Version 2 no longer fits on an NTAG213, so those keep the first version. SpoolBuddy picks by the size the tag reports, and falls back to the first version if it can't read it; a SpoolBuddy that hasn't been updated keeps writing the first version. Bambuddy does not track density or bed temperature, so a version 2 tag leaves them unset. Wiki updated.
+- **Profile defaults is compact and has Apply all** — Settings → Filament → Profile defaults showed every default as a large box with all its presets and K profiles, so a few products already filled the page. Each default is now one line (product, colour, slicer preset, and how many printer presets and K profiles it has), sorted by product, with a search box; click a line to see its presets and K profiles. The list scrolls on its own. **Apply all** applies every default to the spools in stock in one go, and like Apply to existing spools it only fills in what a spool doesn't have yet. Wiki updated.
+- **Slicer sidecars on Bambu Studio 02.09.01.70 and OrcaSlicer 2.5.0 (#3442)** — The Bambu Studio sidecar now ships the H3C printer presets (0.2, 0.4, 0.6 and 0.8 mm nozzle). OrcaSlicer has no H3C presets yet. OrcaSlicer 2.5.0 ships its bundled presets in a new packed format, so the sidecar image now fetches the same release's presets as JSON for its preset handling. Slicing with a printer preset saved under a name of its own keeps working on 2.5.0; the sliced file then names the Bambu preset it was saved from as its printer preset. Sidecar images built for older slicer versions work as before.
+- **"Any model" jobs go to the printer with the fewest print hours (#3480, sponsor vote Q4 2026)** — When several printers of a model can take a job, the queue used to pick the first one in its list, so on a farm the same printer got most of the work. It now picks the one with the fewest print hours (the Maintenance counter, including baseline hours entered there), so jobs and wear spread across the farm. Preferred colours still come first, and the queue powers on the least used printer too when it has to switch one on with an Auto Power On plug.
+
+### Fixed
+- **Closing a camera view no longer leaves a CPU core at 100% (#3515, reported by @BrainDeLook)** — A viewer leaving while its stream was reading from the SQLite database could leave that connection's close waiting forever, on a bug in aiosqlite (omnilib/aiosqlite#403). Bambuddy then kept one CPU core fully busy, even with every viewer closed, and other pages such as Projects and Spool Inventory became very slow until a restart. Bambuddy now works around the aiosqlite bug, for every database connection, not only the camera's, and the camera stream's short reads (the chamber light settings and the periodic access check) finish before a leaving viewer ends the stream. PostgreSQL was not affected.
+- **The external spool keeps its filament after Configure Slot on an A1 or A1 mini (#3513, reported by @SaadShaya7)** — These printers report a change to the external spool with only what changed, often just the colour. Bambuddy stored that as the whole slot, so after a colour or preset change the spool showed as empty, with no filament type, until the printer's next full update. The check that the printer took the new preset also could not find the slot and reported it as not confirmed. A partial update now changes only what it carries, and the check confirms the new preset as soon as the printer reports it.
+- **A print that fails before its first layer no longer charges a whole spool (#3511)** — When a print failed or was cancelled before its first layer, Bambuddy worked out from the sliced file that no filament was used, but still let the AMS remaining % decide for that slot. The AMS can read 0% for a moment as the filament unloads, so a new spool at 100% was charged in full: 1000 g and its cost for a print stopped after five minutes, which then topped the Stats page as the heaviest and most expensive print. A slot whose spool the sliced file already accounts for is now left to that figure, so such a print charges nothing. Prints without a sliced file still use the AMS remaining %.
+- **The H3C's printer card shows the temperature of the hotend on the toolhead (#3442, reported by @hurr1k4ne, cause found by @daltonclift)** — After a tool swap the H3C keeps reporting the temperature of the hotend that just went back to the rack, so the card could show one cooling down there instead of the one printing. The card now takes the temperature, target and heating state of the hotend on the toolhead, as Bambu Studio does, and names it by its rack position (**Nozzle 5**), the number its chip has in the nozzle rack beside it.
+- **A nozzle's hover card says whether it is on the toolhead (#3442, frame from @daltonclift)** — The hover card of a nozzle took a status flag the printer uses for whether the nozzle's details can be trusted as "Mounted". On an H3C, a hotend without RFID in the rack showed as **Mounted**, and the one on the toolhead as **Docked**. The card now says **Mounted** for the nozzles on the toolhead and **Docked** for those in the rack, as Bambu Studio tells them apart.
+- **The SpoolBuddy kiosk works with its own device when several are registered (#3504, reported by @pleasantone)** — The kiosk's **Write** page always sent the write to the first registered device, which could be another kiosk or an old reader that had long been offline. The online state and display settings also came from that device, and every kiosk showed the weight and tags of every reader. The kiosk now works with one device: the one picked on its **Write** page, or else the device that is online. **Write** shows a choice of device when more than one is online, and the kiosk remembers it. Weight, tags and online state from other devices no longer show on it.
+- **A tag written with SpoolBuddy moves to the new spool (#3505, reported by @pleasantone)** — With Bambuddy's own inventory, writing a tag that already belonged to another spool linked it to the new spool but left it on the old one too, so scanning it could open the old spool. The tag now moves: the spool that had it gives it up, as it already did with Spoolman.
+- **SpoolBuddy checks a tag write instead of always reporting success (#3076, reported by @jinjiangz, gaps found by @pleasantone)** — The PN5180 can't see a tag's answer to a write, so **Write Tag** reported success for every page it sent. SpoolBuddy now reads the written data back and fails the write if the tag holds something else, naming the page. If the tag doesn't answer the read-back, the write still counts, reported as not verified. A tag too small for the data is refused before anything is written, and so is a tag that isn't NDEF-formatted, such as a blank MIFARE Ultralight, which looks like an NTAG to the reader. Before, a write past its end was reported as successful. When the write's result couldn't reach Bambuddy straight away, the next heartbeat brought the same write back and SpoolBuddy ran it a second time, on whatever tag was then on the reader. It now waits until the result is through.
+- **HMS notifications are no longer sent again after a restart (#3502, reported by @pleasantone)** — The fix for #3486 remembers which printer errors were already notified, in a table that was never created: startup did not load its model, so every start logged "no such table: hms_notified_faults" and the errors were sent again. The table is now created on the next start.
+- **A backup from Windows restored on Linux or macOS finds its files (#3501, reported by @Tech0verlord)** — A Windows install stores the location of each archive, library file and print log entry with backslashes, which Linux and macOS read as part of the file name. After restoring a Windows backup on Docker or another Linux install, every archive showed no thumbnail and refused its 3D preview and reprint with "Archive data unavailable". The restore now turns those backslashes into slashes. It runs only during a restore, does nothing on Windows, and leaves a path alone when a file with that exact name exists. An install already restored from a Windows backup is fixed by restoring the backup again after updating.
+- **Tags written by SpoolBuddy can be read by phones and other readers (#3500, reported by @pleasantone; likely #3076)** — SpoolBuddy's **Write Tag** wrote a copy of the tag's Capability Container in front of the OpenTag3D data. On an NTAG tag that copy landed in the data area, where a phone, NFC Tools or another OpenTag3D reader took it for unknown data, skipped over the spool record and reported an empty tag, although the write had succeeded. Bambuddy itself was unaffected, since it links spools by the tag's ID. The data now starts with the spool record, and the tag's own Capability Container is left as it is. Tags written before need writing again for other readers to see them.
+- **An A2L with an AMS and an AMS Lite shows both (#3499, reported by @auberonkai)** — With an AMS 2 Pro and an AMS Lite attached to an A2L at the same time, the printer card showed only the external spool, until one of them was unplugged and the printer reconnected. Bambuddy failed to sort the two units, because the printer names a regular AMS with text and the AMS Lite with a number, and from then on it could not read the AMS data of any later update. Both units now show, in order, and an AMS update that can't be read no longer blocks the ones after it. The connection diagnostic also no longer fails an A2L on "Store sent files on external storage": neither the printer nor the slicers offer that option for it, so the check is skipped, as on the P1 series, and the explanation no longer names the P1 series only.
+- **Sliced projects keep their changed settings in Bambu Studio (#3497, reported by @mdbook)** — A 3MF sliced in Bambuddy with a system process preset (a bundled one, or Bambu's stock copy on Bambu Cloud, which is what is picked when signed in) carries the settings changed on top of it: what was set in the slice dialog's process settings, the designer's settings that were kept ticked, and the source file's supports. The slicer wrote their values into the project but did not mark them as changed from the preset, so Bambu Studio opened the project with the preset's own values and a re-slice there lost them. They are now marked as changed. Slices with the user's own presets (Bambu Cloud, Orca Cloud or local), and those that use the file's own settings, are unchanged.
+- **A restart no longer sends printer errors again, and their titles name the right part (#3486, reported by @SaadShaya7)** — Printers keep some faults in their error list long after they happened, without showing them on the screen, for example "There was an unfinished print job when the printer lost power" after a power cut. Bambuddy remembered which faults it had already notified only until it restarted, so every restart sent each of those faults again. The notified faults are now stored, so a restart only notifies faults that are new. The title of a printer error notification also named the wrong part for several kinds of fault: USB, network and file errors came as "Nozzle/Extruder Error", camera faults as "Motion Controller Error", and an A1 mini's AMS Lite runouts as "Chamber Error". They now read "System Error", "Camera Error" and "AMS Lite Error"; AMS-HT, AMS 3H, toolhead, tool changer and air filter faults are named too, and a fault of any other kind is titled "Printer Error".
+- **A queue job resumed by the printer finishes in the queue (#3485, reported by @SaadShaya7)** — Bambuddy checks that a print's completion is about the queue job it closes, by comparing the name the printer reports with the name the job was sent under. A printer that picks a print back up, such as an A1 mini resuming after a power cut or a print Bambuddy reconnects to after a restart, reports the file name with ".3mf" on the end instead, so the names no longer matched: the completion was ignored and the job stayed "printing", blocking the printer's queue until it was stopped by hand. The name is now also compared without its extension. The same comparison decides whether a print counts as started outside Bambuddy, so with **Also ask for prints started outside Bambuddy** on, such a resumed queue job is no longer asked about as an external print. When a power cut also restarts Bambuddy, it comes back while the printer is still paused in power-loss recovery, and the resume was taken for a new print: a second archive of the same print, left "printing". A print found paused when Bambuddy starts now carries on as the same print once it resumes, as one found printing already did; this also covers a print paused by hand before a restart.
+- **A printer prompt answered on the printer leaves the HMS list (#3487)** — Some faults, such as the A1 mini's "is the filament extruded?" prompt while loading filament, come in the printer's print_error field rather than its HMS list. Bambuddy showed them with the HMS errors but did not notice when the printer set that field back to 0, so the entry stayed on the printer card until "Clear errors" was clicked. It now goes as soon as the printer clears it, and a newer code replaces the old one. An HMS list update that arrives while such a fault is still active no longer drops it either.
+- **Failed and cancelled prints get their timelapse too (#3481)** — The printer saves the timelapse of a print that fails or is cancelled, up to where it stopped, and that video is the best way to see what went wrong. Bambuddy fetched timelapses only for prints that completed, so a stopped print's video stayed on the printer until it was attached by hand with **Scan for Timelapse**. It is now fetched and attached to that print's run like any other. Wiki updated.
+- **A printer named after its model no longer hides the model in the support package (#3442, reported by @daltonclift)** — The support package replaces printer names with [PRINTER]. A printer named "H3C" turned every "H3C" in the package into [PRINTER], including the model itself. A name that is only a model name or model code is not private, so it now stays; any other name is still replaced.
+- **Auto-drying no longer waits on a drying timer the printer never started (#3478, reported by @superflyer11)** — An AMS HT can accept a drying command while it feeds a print and never heat: its timer stays at the full time (12h 0m) and its status reads Off, for days. Since 1.3.1b1 the printer card shows such a timer as not running, but auto-drying still took it for a running cycle and never dried that unit again, while the humidity alarms kept firing. A timer that has not moved for a few minutes with the status Off now counts as not drying, so auto-drying tries again after its usual 30-minute pause. If that happens twice in a row, auto-drying stops on that unit and sends the **Auto-Drying Suspended** notification, saying the timer stood still (new template **Auto-Drying Not Heating**). It resumes once the humidity is back at or below the threshold. Wiki updated.
+- **The log says which auto-archive switch skipped a print (#3477, reported by @Fuzzylog2x)** — Since 1.3.0 the global **Auto-archive prints** switch (Settings > General > Archive Settings) takes effect for prints started outside Bambuddy. Before, it was saved and never read, so an install upgraded with it off stopped archiving, and the statistics stopped updating. The log only said "auto_archive: False", the same as for a printer's own switch. It now names the global switch or the printer, whichever turned archiving off. The switch's description now says what off means: those prints are missing from the archive, the print history and the statistics, while spool inventory still counts their filament. The wiki explains how to keep statistics without keeping every 3MF (auto-purge removes the files and keeps the statistics), and no longer says auto-purge removes prints from the statistics.
+- **A camera window no longer opens a dozen streams to the printer at once (#3431, reported by @buffdriver)** — When the floating camera window was open while the page loaded (after a refresh, for example), Bambuddy opened about twenty camera streams for it. Only one was shown. The browser kept the rest open until the tab was closed, and they filled its six connections to the server. The page started each stream while still preparing to draw the window. It prepared the window several times before showing it, and every attempt left a stream behind. Streams now start only once the window is on the page. The camera page, the camera wall and the stream overlay start their streams the same way.
+- **A printer whose file service hangs during a print is no longer asked again and again until it refuses every upload (#3474, reported by @F9mm)** — When the card could not be read at print start, the timelapse baseline was read again every two minutes for the whole print, and each round walked the card twice: up to eight new FTPS connections, each hanging for 30 s against a hung printer. An H2S kept a session for every one of them, then answered every connection with "421 There are too many connections", and no queued job could reach it until the printer was restarted. The card is now read once per round. It is read again only while the print records a timelapse, the wait doubles after each failed round (up to 30 minutes), and it stops after five. A connection or listing that times out now pauses the background timelapse scans of that printer for five minutes. Opening files and sending prints are not held up by this. A printer that answers "421 too many connections" now gets its own message in the queue, saying it may need a restart, instead of "Bambuddy will retry automatically". Wiki updated.
+
 ## [1.3.0] - 2026-10-06
 
 ### Added

+ 5 - 0
backend/app/api/routes/camera.py

@@ -14,6 +14,7 @@ import zlib
 from collections.abc import AsyncGenerator
 from typing import Annotated
 
+import anyio.lowlevel
 from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request
 from fastapi.responses import Response, StreamingResponse
 from pydantic import BaseModel
@@ -1098,6 +1099,10 @@ async def _while_allowed(frames, opener: StreamOpener, printer_id: int):
                     if time.monotonic() < next_check:
                         yield chunk
                         continue
+                    # The re-check is shielded, so a viewer who left while it
+                    # waited is only told now: let that land before a second
+                    # shielded wait, which would keep the stream (#3515).
+                    await anyio.lowlevel.checkpoint()
                     allowed = await _opener_allowed(opener, printer_id)
                 if not allowed:
                     logger.info("Camera stream for printer %s ended: its opener may no longer watch", printer_id)

+ 2 - 1
backend/app/api/routes/inventory.py

@@ -106,6 +106,7 @@ from backend.app.utils.filament_types import nozzle_temp_range, printer_filament
 from backend.app.utils.local_time import day_zone, local_date_end, local_date_start
 from backend.app.utils.natural_sort import natural_sort_key
 from backend.app.utils.tag_normalization import normalize_tag_uid, normalize_tray_uuid
+from backend.app.utils.tray_ids import external_tray_id
 
 logger = logging.getLogger(__name__)
 
@@ -2460,7 +2461,7 @@ async def assign_spool(
         if data.ams_id == 255:
             # External slot: look up tray from vt_tray by global ID
             vt_tray = state.raw_data.get("vt_tray") or []
-            ext_id = data.tray_id + 254  # 0→254, 1→255
+            ext_id = external_tray_id(data.tray_id)  # 0→254, 1→255, rack 2-8→247-253
             for vt in vt_tray:
                 if isinstance(vt, dict) and int(vt.get("id", 254)) == ext_id:
                     fingerprint_color = vt.get("tray_color", "")

+ 25 - 0
backend/app/api/routes/library.py

@@ -85,8 +85,10 @@ from backend.app.services.archive import ThreeMFParser, estimate_filament_cost
 from backend.app.services.design_settings import (
     DesignOverride,
     apply_design_overrides,
+    changed_process_keys,
     extract_design_process_overrides,
     overrides_from_config,
+    record_process_changes,
 )
 from backend.app.services.filament_requirements import annotate_rack_groups
 from backend.app.services.library_folder_access import (
@@ -4461,6 +4463,9 @@ async def _run_slicer_with_fallback(
     # the embedded plate / model definitions remain intact.
     is_3mf = model_filename.lower().endswith(".3mf")
     primary_bytes = model_bytes
+    # The process JSON before Bambuddy layers its own settings on the picked
+    # preset, to tell which keys the output 3MF has to list as changed (#3497).
+    process_as_picked = presets["process"]
     if is_3mf:
         # Strip inherit/unset sentinels from Metadata/project_settings.config
         # so the CLI's StaticPrintConfig range validator accepts the file
@@ -4514,6 +4519,23 @@ async def _run_slicer_with_fallback(
     if request.process_overrides:
         presets["process"] = apply_process_overrides(presets["process"], request.process_overrides)
 
+    # #3497: the slicer CLI leaves different_settings_to_system empty, so the
+    # keys set above are not marked as changed in the output 3MF and Bambu
+    # Studio reopens it with the stock preset's values. Only for a system
+    # process preset, bundled (standard) or Bambu's stock copy on Bambu Cloud,
+    # which signed-in users pick instead (#3386): the system preset is then
+    # the base those keys differ from. A private cloud, Orca Cloud or local
+    # preset already is the user's own preset.
+    from backend.app.api.routes.slicer_presets import is_stock_cloud_preset
+
+    process_ref = request.process_preset
+    process_changes = (
+        changed_process_keys(process_as_picked, presets["process"])
+        if process_ref is not None
+        and (process_ref.source == "standard" or await is_stock_cloud_preset(db, user, process_ref, "process"))
+        else []
+    )
+
     used_embedded_settings = False
     # "Slice as designed" (#2611): honour the file's embedded
     # project_settings.config instead of the picked profile triplet. Only
@@ -4867,6 +4889,9 @@ async def _run_slicer_with_fallback(
     # 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 process_changes and request.export_3mf and not used_embedded_settings:
+        result = result._replace(content=record_process_changes(result.content, process_changes))
+
     if not used_embedded_settings:
         unresolved = unresolved_filament_slots(result.content, export_3mf=bool(request.export_3mf))
         if unresolved:

+ 192 - 4
backend/app/api/routes/maintenance.py

@@ -32,7 +32,7 @@ from backend.app.schemas.maintenance import (
     PrinterMaintenanceUpdate,
 )
 from backend.app.services.notification_service import notification_service
-from backend.app.utils.printer_models import get_rod_type
+from backend.app.utils.printer_models import get_rod_type, normalize_printer_model_id
 
 logger = logging.getLogger(__name__)
 
@@ -102,6 +102,147 @@ DEFAULT_MAINTENANCE_TYPES = [
     },
 ]
 
+# H3C tasks, from Bambu's H3C and AMS 3H maintenance guides. Those give an
+# interval only for the air filter (1440 h of printing); the others follow
+# Bambu's H2C table (/en/h2c/maintenance/period-maintenance), and the AMS 3H
+# ones, which have none published, use conservative defaults. All intervals are
+# editable per printer.
+_H3C_WIKI = "https://wiki.bambulab.com/en/h3c"
+H3C_MAINTENANCE_TYPES = [
+    {
+        "name": "Lubricate X/Y Rails (H3C)",
+        "description": "Wipe old oil and dust off the X and Y guide rails, put a drop of lubricant oil along "
+        "each (oil only, no grease), then run Vibration Compensation",
+        "default_interval_hours": 30.0,
+        "interval_type": "days",
+        "icon": "Droplet",
+        "wiki_url": f"{_H3C_WIKI}/maintenance/lubricate-x-y-z-axis",
+    },
+    {
+        "name": "Grease Z Screws (H3C)",
+        "description": "Wipe and lightly grease all three Z lead screws, wipe and oil the Z linear rods, "
+        "then run Vibration Compensation",
+        "default_interval_hours": 90.0,
+        "interval_type": "days",
+        "icon": "Droplet",
+        "wiki_url": f"{_H3C_WIKI}/maintenance/lubricate-x-y-z-axis",
+    },
+    {
+        "name": "Oil XY Idler Pulleys (H3C)",
+        "description": "Wipe the 12 XY idler pulleys and oil their bearings; sooner if the toolhead gets noisy",
+        "default_interval_hours": 90.0,
+        "interval_type": "days",
+        "icon": "Cog",
+        "wiki_url": f"{_H3C_WIKI}/maintenance/idle-gear",
+    },
+    {
+        "name": "Grease Toolhead (H3C)",
+        "description": "Take off the toolhead front-left cover, grease the pitch change cam and the ToolLock pin, "
+        "and turn both motors with the release socket to spread it",
+        "default_interval_hours": 90.0,
+        "interval_type": "days",
+        "icon": "Wrench",
+        "wiki_url": f"{_H3C_WIKI}/maintenance/periodic-maintenance",
+    },
+    {
+        "name": "Oil SwapTek Rack (H3C)",
+        "description": "Wipe both ends of the SwapTek rack hanger, add a drop of lubricating oil to each bearing "
+        "and push the hanger up and down",
+        "default_interval_hours": 30.0,
+        "interval_type": "days",
+        "icon": "Layers",
+        "wiki_url": f"{_H3C_WIKI}/maintenance/periodic-maintenance",
+    },
+    {
+        "name": "Clean Hotends (H3C)",
+        "description": "Fetch each hotend, heat it above the filament's softening point and wipe it "
+        "with the nozzle cleaning sponge",
+        "default_interval_hours": 100.0,
+        "interval_type": "hours",
+        "icon": "Flame",
+        "wiki_url": f"{_H3C_WIKI}/maintenance/periodic-maintenance",
+    },
+    {
+        "name": "Clean Extruder Wheel (H3C)",
+        "description": "Check the toolhead extruder wheel for filament debris and brush it clean",
+        "default_interval_hours": 7.0,
+        "interval_type": "days",
+        "icon": "CircleDot",
+        "wiki_url": f"{_H3C_WIKI}/troubleshooting/extruder-cleaning-guide",
+    },
+    {
+        "name": "Clean Cameras (H3C)",
+        "description": "Wipe the live view, toolhead and BirdsEye camera lenses with a cleaning wipe",
+        "default_interval_hours": 90.0,
+        "interval_type": "days",
+        "icon": "Target",
+        "wiki_url": f"{_H3C_WIKI}/maintenance/periodic-maintenance",
+    },
+    {
+        "name": "Clean Fans (H3C)",
+        "description": "Brush the dust off the part cooling, hotend cooling and chamber cooling fans",
+        "default_interval_hours": 90.0,
+        "interval_type": "days",
+        "icon": "Fan",
+        "wiki_url": f"{_H3C_WIKI}/maintenance/periodic-maintenance",
+    },
+    {
+        "name": "Replace Air Filter (H3C)",
+        "description": "Bambu rates the filter for 1440 hours of printing (about 60 days)",
+        "default_interval_hours": 1440.0,
+        "interval_type": "hours",
+        "icon": "Filter",
+        "wiki_url": f"{_H3C_WIKI}/maintenance/replace-air-filter",
+    },
+    {
+        "name": "Check Filament Tubes (H3C)",
+        "description": "Look for tubes bent out of shape behind the purge wiper and for worn AMS 3H tubes; "
+        "straighten or replace them",
+        "default_interval_hours": 500.0,
+        "interval_type": "hours",
+        "icon": "Cable",
+        "wiki_url": f"{_H3C_WIKI}/maintenance/periodic-maintenance",
+    },
+    {
+        "name": "AMS 3H: Check Desiccant",
+        "description": "Check the desiccant on the drying chamber cover; dry or replace it when it is spent",
+        "default_interval_hours": 30.0,
+        "interval_type": "days",
+        "icon": "Wind",
+        "wiki_url": f"{_H3C_WIKI}/maintenance/ams_3h",
+    },
+    {
+        "name": "AMS 3H: Inlets & Gears",
+        "description": "Clear filament debris from the inlet funnels, grease the loading gears and the "
+        "air inlet/outlet flap gears",
+        "default_interval_hours": 90.0,
+        "interval_type": "days",
+        "icon": "Box",
+        "wiki_url": f"{_H3C_WIKI}/maintenance/ams_3h",
+    },
+    {
+        "name": "AMS 3H: Clean Dryer Fans",
+        "description": "Unload all filament, lift the AMS 3H off and brush the dust off the heating unit fans",
+        "default_interval_hours": 180.0,
+        "interval_type": "days",
+        "icon": "Thermometer",
+        "wiki_url": f"{_H3C_WIKI}/maintenance/ams_3h",
+    },
+]
+# System types of one model (short name): seeded and listed only while a
+# printer of that model exists, so other installs see just the defaults above.
+MODEL_MAINTENANCE_TYPES: dict[str, list[dict]] = {"H3C": H3C_MAINTENANCE_TYPES}
+_MODEL_ONLY_TYPES: dict[str, str] = {t["name"]: m for m, types in MODEL_MAINTENANCE_TYPES.items() for t in types}
+
+_H3C_MODELS = frozenset(["H3C"])
+# Generic system types a model's own one replaces there.
+_REPLACED_ON_MODELS: dict[str, frozenset[str]] = {
+    "Lubricate Linear Rails": _H3C_MODELS,  # -> X/Y rails + Z lead screws
+    "Clean Linear Rails": _H3C_MODELS,  # -> X/Y rails (wiped before oiling)
+    "Clean Nozzle/Hotend": _H3C_MODELS,  # -> Clean Hotends
+    "Check PTFE Tube": _H3C_MODELS,  # -> Check Filament Tubes
+}
+
 # System types that only apply to printers with a specific rod/rail type.
 # "carbon" = X1/P1 series (carbon rods), "steel_rod" = P2S (steel rods),
 # "linear_rail" = A1/H2 series. Types not listed here apply to all printers.
@@ -114,8 +255,23 @@ _ROD_TYPE_REQUIREMENTS: dict[str, str] = {
 }
 
 
+def _short_model(printer_model: str | None) -> str:
+    """ "N10" or "H3C" -> "H3C": the short name, uppercase, no spaces or dashes."""
+    if not printer_model:
+        return ""
+    short = normalize_printer_model_id(printer_model.strip()) or ""
+    return short.upper().replace(" ", "").replace("-", "")
+
+
 def _should_apply_to_printer(type_name: str, printer_model: str | None) -> bool:
     """Check if a system maintenance type should apply to a given printer model."""
+    model = _short_model(printer_model)
+    only_on = _MODEL_ONLY_TYPES.get(type_name)
+    if only_on is not None:
+        return model == only_on
+    if model in _REPLACED_ON_MODELS.get(type_name, frozenset()):
+        return False
+
     rod_requirement = _ROD_TYPE_REQUIREMENTS.get(type_name)
     if rod_requirement is None:
         return True  # Not model-specific, applies to all
@@ -150,6 +306,18 @@ async def get_printer_total_hours(db: AsyncSession, printer_id: int) -> float:
     return runtime_hours + offset
 
 
+async def _printer_models_present(db: AsyncSession) -> set[str]:
+    """The short model names of the printers this install has."""
+    models = (await db.execute(select(Printer.model).distinct())).scalars().all()
+    return {_short_model(m) for m in models if m}
+
+
+def _hidden_model_type(maint_type: MaintenanceType, models_present: set[str]) -> bool:
+    """A model's own system type while no printer of that model exists."""
+    model = _MODEL_ONLY_TYPES.get(maint_type.name)
+    return bool(maint_type.is_system) and model is not None and model not in models_present
+
+
 async def ensure_default_types(db: AsyncSession) -> None:
     """Ensure default maintenance types exist, remove stale/duplicate ones."""
     result = await db.execute(
@@ -157,7 +325,12 @@ async def ensure_default_types(db: AsyncSession) -> None:
     )
     existing = result.scalars().all()
 
-    default_names = {t["name"] for t in DEFAULT_MAINTENANCE_TYPES}
+    models_present = await _printer_models_present(db)
+    wanted = DEFAULT_MAINTENANCE_TYPES + [
+        type_def for model, types in MODEL_MAINTENANCE_TYPES.items() if model in models_present for type_def in types
+    ]
+    # A model's types are kept (hidden) while no printer of it exists.
+    default_names = {t["name"] for t in DEFAULT_MAINTENANCE_TYPES} | set(_MODEL_ONLY_TYPES)
 
     # Remove stale system types no longer in defaults (e.g. renamed types)
     # and deduplicate: if concurrent requests created the same type twice,
@@ -170,13 +343,15 @@ async def ensure_default_types(db: AsyncSession) -> None:
             seen_names.add(t.name)
 
     # Create any missing default types
-    for type_def in DEFAULT_MAINTENANCE_TYPES:
+    for type_def in wanted:
         if type_def["name"] not in seen_names:
             new_type = MaintenanceType(
                 name=type_def["name"],
                 description=type_def["description"],
                 default_interval_hours=type_def["default_interval_hours"],
+                interval_type=type_def.get("interval_type", "hours"),
                 icon=type_def["icon"],
+                wiki_url=type_def.get("wiki_url"),
                 is_system=True,
             )
             db.add(new_type)
@@ -199,7 +374,8 @@ async def get_maintenance_types(
         .where(MaintenanceType.is_deleted.is_(False))
         .order_by(MaintenanceType.is_system.desc(), MaintenanceType.name)
     )
-    return result.scalars().all()
+    models_present = await _printer_models_present(db)
+    return [t for t in result.scalars().all() if not _hidden_model_type(t, models_present)]
 
 
 @router.post("/types", response_model=MaintenanceTypeResponse)
@@ -330,6 +506,16 @@ async def _get_or_create_maintenance_item(db: AsyncSession, printer_id: int, typ
         return existing
 
 
+def _start_model_task_clock(item: PrinterMaintenance, maint_type: MaintenanceType, printer: Printer, now) -> None:
+    """Count a model's own task from when the printer was added, not from "never".
+
+    Otherwise a new printer's calendar tasks all open as overdue. Fills only an
+    empty last_performed_at and writes no history row.
+    """
+    if maint_type.name in _MODEL_ONLY_TYPES and item.last_performed_at is None:
+        item.last_performed_at = printer.created_at or now.replace(tzinfo=None)
+
+
 async def _get_printer_maintenance_internal(
     printer_id: int,
     db: AsyncSession,
@@ -374,6 +560,7 @@ async def _get_printer_maintenance_internal(
         default_interval_type = getattr(maint_type, "interval_type", "hours") or "hours"
 
         if item:
+            _start_model_task_clock(item, maint_type, printer, now)
             interval = item.custom_interval_hours or maint_type.default_interval_hours
             # Use custom interval type if set, otherwise use type's default
             interval_type = getattr(item, "custom_interval_type", None) or default_interval_type
@@ -390,6 +577,7 @@ async def _get_printer_maintenance_internal(
             # Create default entry for this printer/type, or take the one a
             # concurrent read just created.
             item = await _get_or_create_maintenance_item(db, printer_id, maint_type.id)
+            _start_model_task_clock(item, maint_type, printer, now)
 
             interval = item.custom_interval_hours or maint_type.default_interval_hours
             interval_type = getattr(item, "custom_interval_type", None) or default_interval_type

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

@@ -43,6 +43,7 @@ EVENT_NAMES = {
     "ams_humidity_high": "AMS Humidity High",
     "ams_temperature_high": "AMS Temperature High",
     "ams_drying_suspended": "Auto-Drying Suspended",
+    "ams_drying_not_heating": "Auto-Drying Not Heating",
     "ha_sensor_alert": "Printer Sensor Alert",
     "location_ha_sensor_alert": "Storage Location Sensor Alert",
     "filament_low": "Filament Low",

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

@@ -5,6 +5,7 @@ import secrets
 import zipfile
 from pathlib import Path
 
+import httpx
 from fastapi import APIRouter, Depends, HTTPException, Query
 from fastapi.responses import FileResponse, Response
 from sqlalchemy import delete, func, select
@@ -50,6 +51,10 @@ from backend.app.schemas.printer import (
     PrinterStatus,
     PrinterUpdate,
     PrintOptionsResponse,
+    WLEDConnectionInfo,
+    WLEDPreset,
+    WLEDPresetListRequest,
+    WLEDPresetTestRequest,
     hms_error_responses,
 )
 from backend.app.services import camera_light as camera_light_service, drying_preflight, kprofile_drift
@@ -102,6 +107,7 @@ from backend.app.services.printer_media import (
 )
 from backend.app.services.slicer_filament_resolver import _ORCA_PROFILE_ID, lookup_orca_filament_id
 from backend.app.services.slot_nozzle import reports_two_nozzles, resolve_slot_nozzle
+from backend.app.services.wled import WLEDResponseError, wled_manager
 from backend.app.utils.filament_finish import different_finish, finish_generic_id
 from backend.app.utils.filament_ids import (
     GENERIC_FILAMENT_IDS,
@@ -114,6 +120,15 @@ from backend.app.utils.fts_routing import slot_extruder
 from backend.app.utils.http import build_content_disposition, download_error_response, safe_download_filename
 from backend.app.utils.kprofile_lookup import build_slot_k_resolver
 from backend.app.utils.printer_models import MAX_CHAMBER_TEMP_C, aux_fan_sides, uses_exhaust_fan_label
+from backend.app.utils.tray_ids import (
+    AMS_HT_IDS,
+    EXTERNAL_AMS_ID,
+    TrayLayout,
+    external_tray_id,
+    global_tray_id as studio_global_tray_id,
+    is_ams_ht_id,
+    legacy_tray_index,
+)
 
 logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/printers", tags=["printers"])
@@ -227,6 +242,8 @@ async def create_printer(
     if await location_grantees(db, [printer.location]):
         await ws_manager.refresh_printer_scopes()
 
+    wled_manager.configure_printer(printer.id, printer.wled_config)
+
     # Connect to the printer
     if printer.is_active:
         await printer_manager.connect_printer(printer)
@@ -234,6 +251,57 @@ async def create_printer(
     return printer
 
 
+@router.post("/{printer_id}/wled/presets", response_model=list[WLEDPreset])
+async def list_wled_presets(
+    printer_id: int,
+    request: WLEDPresetListRequest,
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
+    db: AsyncSession = Depends(get_db, scope="function"),
+):
+    """Read existing presets from WLED without changing its configuration."""
+    if not await db.scalar(select(Printer.id).where(Printer.id == printer_id)):
+        raise HTTPException(404, "Printer not found")
+    try:
+        return await wled_manager.list_presets(request.base_url)
+    except httpx.HTTPStatusError as exc:
+        raise HTTPException(502, f"WLED returned HTTP {exc.response.status_code}") from exc
+    except (httpx.HTTPError, WLEDResponseError) as exc:
+        raise HTTPException(502, "Could not load WLED presets") from exc
+
+
+@router.post("/{printer_id}/wled/test-connection", response_model=WLEDConnectionInfo)
+async def test_wled_connection(
+    printer_id: int,
+    request: WLEDPresetListRequest,
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
+    db: AsyncSession = Depends(get_db, scope="function"),
+):
+    """Check WLED connectivity and return its optional identity."""
+    if not await db.scalar(select(Printer.id).where(Printer.id == printer_id)):
+        raise HTTPException(404, "Printer not found")
+    try:
+        return await wled_manager.get_info(request.base_url)
+    except httpx.HTTPStatusError as exc:
+        raise HTTPException(502, f"WLED returned HTTP {exc.response.status_code}") from exc
+    except (httpx.HTTPError, WLEDResponseError) as exc:
+        raise HTTPException(502, "Could not connect to WLED") from exc
+
+
+@router.post("/{printer_id}/wled/test-preset")
+async def test_wled_preset(
+    printer_id: int,
+    request: WLEDPresetTestRequest,
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
+    db: AsyncSession = Depends(get_db, scope="function"),
+):
+    """Activate one validated preset without changing stored configuration."""
+    if not await db.scalar(select(Printer.id).where(Printer.id == printer_id)):
+        raise HTTPException(404, "Printer not found")
+    if not await wled_manager.test_preset(printer_id, request.base_url, request.preset_id):
+        raise HTTPException(502, "Could not activate WLED preset")
+    return {"success": True}
+
+
 @router.get("/usb-cameras")
 async def list_usb_cameras(
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
@@ -469,11 +537,23 @@ async def update_printer(
         except Exception as e:
             logger.error("Failed to sync virtual printers after printer IP change: %s", e)
 
+    if "wled_config" in update_data:
+        wled_manager.configure_printer(printer_id, printer.wled_config)
+        state = printer_manager.get_status(printer_id)
+        if state and printer.is_active:
+            wled_manager.handle_status(
+                printer_id,
+                state,
+                awaiting_plate_clear=printer_manager.is_awaiting_plate_clear(printer_id),
+            )
+
     # Reconnect if connection settings changed
     if any(k in update_data for k in ["ip_address", "access_code", "is_active"]):
         printer_manager.disconnect_printer(printer_id)
         if printer.is_active:
             await printer_manager.connect_printer(printer)
+        elif "is_active" in update_data:
+            wled_manager.handle_offline(printer_id)
 
     return printer
 
@@ -540,6 +620,7 @@ async def delete_printer(
         notify = await delete_provider(db, provider) or notify
 
     printer_manager.disconnect_printer(printer_id)
+    wled_manager.remove_printer(printer_id)
 
     # Locked from the archive sweep to the commit (PostgreSQL; SQLite's single
     # writer serialises this anyway). A print start adopting or writing an
@@ -695,6 +776,7 @@ async def _printer_status(db: AsyncSession, printer: Printer) -> PrinterStatus:
     # Virtual tray (external spool holder) - comes from vt_tray in raw_data (list)
     if "vt_tray" in raw_data:
         vt_tray = [AMSTray(**vt) for vt in vt_trays_to_dicts(raw_data["vt_tray"], _kprofile_k)]
+    tray_layout = TrayLayout.from_units(ams_units, vt_tray)
 
     # Convert nozzle info to response format
     nozzles = [
@@ -712,12 +794,14 @@ async def _printer_status(db: AsyncSession, printer: Printer) -> PrinterStatus:
             nozzle_type=n.get("type", ""),
             nozzle_diameter=n.get("diameter", ""),
             wear=n.get("wear"),
+            print_time_s=n.get("print_time_s"),
             stat=n.get("stat"),
             max_temp=n.get("max_temp", 0),
             serial_number=n.get("serial_number", ""),
             filament_color=n.get("filament_color", ""),
             filament_id=n.get("filament_id", ""),
             filament_type=n.get("filament_type", ""),
+            addr_id=n.get("addr_id"),
         )
         for n in (state.nozzle_rack or [])
     ]
@@ -840,6 +924,7 @@ async def _printer_status(db: AsyncSession, printer: Printer) -> PrinterStatus:
                 raw_data.get("mapping"),
                 active_extruder=state.active_extruder,
                 ams_extruder_map=nozzle_wiring(state),
+                tray_layout=tray_layout,
             )
             if state.state == "PAUSE"
             else None
@@ -851,6 +936,7 @@ async def _printer_status(db: AsyncSession, printer: Printer) -> PrinterStatus:
                 raw_data.get("mapping"),
                 active_extruder=state.active_extruder,
                 ams_extruder_map=nozzle_wiring(state),
+                tray_layout=tray_layout,
             )
             if state.state == "PAUSE"
             else None
@@ -1038,6 +1124,7 @@ async def disconnect_printer(
         raise HTTPException(404, "Printer not found")
 
     printer_manager.disconnect_printer(printer_id)
+    wled_manager.handle_offline(printer_id)
     return {"connected": False}
 
 
@@ -2581,12 +2668,11 @@ async def start_calibration(
 
 
 def _slot_preset_key(ams_id: int, tray_id: int) -> int:
-    # Mirrors frontend getGlobalTrayId (amsHelpers.ts): AMS-HT (128-135) is keyed
-    # by ams_id since each unit has a single slot and shares its global ID with
-    # the unit itself. Regular AMS and external (255) use ams_id*4+tray_id.
-    if 128 <= ams_id <= 135:
-        return ams_id
-    return ams_id * 4 + tray_id
+    # Mirrors frontend getGlobalTrayId (amsHelpers.ts): the slot's global tray
+    # id; the external inputs keep their 255 * 4 + tray_id key.
+    if ams_id == EXTERNAL_AMS_ID:
+        return legacy_tray_index(ams_id, tray_id)
+    return studio_global_tray_id(ams_id, tray_id)
 
 
 @router.get("/{printer_id}/slot-presets")
@@ -3043,7 +3129,7 @@ async def configure_ams_slot(
 
             if ams_id == 255:
                 vt_tray = state.raw_data.get("vt_tray") or []
-                ext_id = tray_id + 254
+                ext_id = external_tray_id(tray_id)
                 for vt in vt_tray:
                     if isinstance(vt, dict) and int(vt.get("id", 254)) == ext_id:
                         current_tray_info_idx = vt.get("tray_info_idx", "")
@@ -3192,16 +3278,16 @@ async def configure_ams_slot(
     # hardcoded extruder_id=0 and nozzle_id=HS00.
     k_value_unsent = False
     if k_value > 0 and cali_idx < 0:
-        # Calculate global tray ID for extrusion_cali_set
+        # extrusion_cali_set's flat tray index; an AMS-HT counts from 128.
         if ams_id <= 3:
-            global_tray_id = ams_id * 4 + tray_id
-        elif ams_id >= 128 and ams_id <= 135:
-            global_tray_id = (ams_id - 128) * 4 + tray_id
+            cali_tray_id = legacy_tray_index(ams_id, tray_id)
+        elif is_ams_ht_id(ams_id):
+            cali_tray_id = legacy_tray_index(ams_id - AMS_HT_IDS.start, tray_id)
         else:
-            global_tray_id = tray_id
+            cali_tray_id = tray_id
 
         k_value_unsent = not client.extrusion_cali_set(
-            tray_id=global_tray_id,
+            tray_id=cali_tray_id,
             k_value=k_value,
             nozzle_diameter=nozzle_diameter,
             nozzle_temp=nozzle_temp_max,
@@ -4641,15 +4727,36 @@ async def _apply_pa_after_refresh(printer_id: int, ams_id: int, slot_id: int):
 
 # 24-27 are the A2L AMS-Lite slots (normalised unit 6 = 6*4+slot); see
 # a2l-am-unit-16. An AMS-HT holds one tray, numbered by its unit id 128-135, as
-# the printer's tray_now reports it. All are valid alongside the regular 0-15.
+# the printer's tray_now reports it. 28-36 are AMS 3H slots, valid only on a
+# printer that has one. All are valid alongside the regular 0-15.
 _LOAD_TRAY_ID_ERROR = (
-    "tray_id must be 0..15 (AMS slot), 24..27 (A2L AMS-Lite), 128..135 (AMS-HT), 254 (external / Ext-L), or 255 (Ext-R)"
+    "tray_id must be 0..15 (AMS slot), 24..27 (A2L AMS-Lite), 28..36 (H3C AMS 3H), 128..135 (AMS-HT), "
+    "254 (external / Ext-L), or 255 (Ext-R)"
 )
+_AMS3H_PRINTER = TrayLayout.ams3h()
 
 
 def _is_valid_load_tray_id(tray_id: int) -> bool:
     """Whether ``tray_id`` names a slot the load/unload commands can address."""
-    return tray_id in range(16) or tray_id in range(24, 28) or tray_id in range(128, 136) or tray_id in (254, 255)
+    return (
+        tray_id in range(16)
+        or tray_id in range(24, 28)
+        or _AMS3H_PRINTER.rack_slot(tray_id) is not None
+        or is_ams_ht_id(tray_id)
+        or tray_id in (254, 255)
+    )
+
+
+def _client_tray_layout(client: object) -> TrayLayout:
+    """The connected printer's tray numbering; empty for a client that offers none."""
+    layout = getattr(client, "tray_layout", None)
+    return layout if isinstance(layout, TrayLayout) else TrayLayout()
+
+
+def _refuse_foreign_ams3h_tray(tray_id: int | None, layout: TrayLayout) -> None:
+    """An AMS 3H slot id names nothing on a printer without that AMS 3H."""
+    if tray_id is not None and _AMS3H_PRINTER.rack_slot(tray_id) is not None and layout.rack_slot(tray_id) is None:
+        raise HTTPException(400, _LOAD_TRAY_ID_ERROR)
 
 
 @router.post("/{printer_id}/ams/load")
@@ -4679,6 +4786,7 @@ async def ams_load(
 
     Tray ID encoding (matches Bambu firmware convention):
     - 0..15: AMS slot, computed as ams_id * 4 + slot_id
+    - 28..36: the H3C's AMS 3H, 28 + slot (its slot feeds its own hotend)
     - 128..135: AMS-HT, the unit id (one tray per unit)
     - 254: external spool (single-external printers, or Ext-L on dual-nozzle H2D)
     - 255: Ext-R on dual-nozzle H2D
@@ -4695,18 +4803,33 @@ async def ams_load(
     if not client:
         raise HTTPException(400, "Printer not connected")
 
+    # An AMS 3H slot has nothing to feed until the AMS has pulled its filament
+    # in at the inlet; Studio offers no Load before that.
+    layout = _client_tray_layout(client)
+    _refuse_foreign_ams3h_tray(tray_id, layout)
+    rack_slot = layout.rack_slot(tray_id)
+    if rack_slot is not None and not client.rack_tray_has_filament(tray_id):
+        raise HTTPException(
+            409,
+            f"AMS 3H slot {rack_slot[1] + 1} has no filament. Push the filament into the slot's inlet "
+            "until the AMS pulls it in, then load it.",
+        )
+
     success = client.ams_load_filament(tray_id, extruder_id=extruder_id)
     if not success:
         raise HTTPException(500, "Failed to send load command")
 
-    if tray_id == 254:
+    if rack_slot is not None:
+        target = f"AMS 3H slot {rack_slot[1] + 1}"
+    elif tray_id == 254:
         target = "external spool"
     elif tray_id == 255:
         target = "Ext-R"
     elif tray_id >= 128:
         target = f"AMS HT-{chr(ord('A') + tray_id - 128)}"
     else:
-        target = f"AMS {tray_id // 4} slot {tray_id % 4 + 1}"
+        unit, slot = layout.slot_key(tray_id)
+        target = f"AMS {unit} slot {slot + 1}"
     return {"success": True, "message": f"Loading filament from {target}"}
 
 
@@ -4738,6 +4861,7 @@ async def ams_unload(
     client = printer_manager.get_client(printer_id)
     if not client:
         raise HTTPException(400, "Printer not connected")
+    _refuse_foreign_ams3h_tray(tray_id, _client_tray_layout(client))
 
     success = client.ams_unload_filament(tray_id)
     if not success:

+ 20 - 2
backend/app/api/routes/settings.py

@@ -506,6 +506,10 @@ async def update_settings(
 
     if {"camera_light_mode", "camera_light_delay"} & set(update_data.keys()):
         camera_light.invalidate_settings()
+    if "require_plate_clear" in update_data:
+        from backend.app.services.wled import wled_manager
+
+        wled_manager.require_plate_clear = update_data["require_plate_clear"]
 
     if price_source_changed:
         # Read a newly chosen price sensor now, so the saved settings show its
@@ -599,6 +603,9 @@ async def reset_settings(
 
     await db.commit()
     camera_light.invalidate_settings()
+    from backend.app.services.wled import wled_manager
+
+    wled_manager.require_plate_clear = False
 
     return DEFAULT_SETTINGS
 
@@ -1777,11 +1784,22 @@ async def restore_backup(
             await reinitialize_database()
             await init_db()
 
+            # 8b. A backup from a Windows install stores its paths with
+            # backslashes, which Linux and macOS read as part of a file name
+            # (#3501). After init_db, so the tables are current, and after the
+            # files are copied, so a path that exists as written is kept.
+            from backend.app.core import database as _db_mod
+            from backend.app.services.backup_paths import convert_windows_paths
+
+            try:
+                async with _db_mod.engine.begin() as conn:
+                    await convert_windows_paths(conn, base_dir)
+            except Exception as e:
+                logger.warning("Could not convert Windows file paths from the backup: %s", e, exc_info=True)
+
             # 9. Sign everyone out. The restored users and auth tokens come from
             # another point in time, possibly written by older code, so no
             # session issued before the restore should stay valid.
-            from backend.app.core import database as _db_mod
-
             async with _db_mod.engine.begin() as conn:
                 await _db_mod.invalidate_all_sessions(conn)
             # Open dashboards of signed-in users too: they are never signed in

+ 15 - 2
backend/app/api/routes/slicer_presets.py

@@ -76,6 +76,7 @@ from backend.app.utils.printer_models import (
     normalize_printer_model_id,
 )
 from backend.app.utils.slicer_strings import first_slicer_string, slicer_strings
+from backend.app.utils.tray_ids import external_slot_index, is_ams_ht_id
 
 logger = logging.getLogger(__name__)
 
@@ -816,6 +817,18 @@ async def load_stock_aliases(db: AsyncSession, user: User | None) -> dict[str, P
     return _stock_aliases(cloud, standard)
 
 
+async def is_stock_cloud_preset(db: AsyncSession, user: User | None, ref: PresetRef, slot: str) -> bool:
+    """Whether ``ref`` is one of Bambu's stock presets on Bambu Cloud (#3497).
+
+    The cloud listing's "public" list, from the same cache as the listing; a
+    listing that can't be fetched counts as not stock.
+    """
+    if ref.source != "cloud":
+        return False
+    cloud, _status = await _fetch_cloud_presets(db, user)
+    return any(p.stock and p.id == ref.id for p in cloud.get(slot, []))
+
+
 def listed_ref(aliases: dict[str, PresetRef], ref: PresetRef) -> PresetRef:
     """The ref the listing shows for ``ref``: its cloud twin if hidden, else itself."""
     return aliases.get(f"{ref.source}:{ref.id}", ref)
@@ -902,7 +915,7 @@ async def list_loaded_spools(
                 for t in raw_trays
             ]
             # Same rule as the printer status: an AMS-HT is a unit id 128-135.
-            units.append(LoadedSpoolUnit(id=ams_id, is_ams_ht=128 <= ams_id <= 135, trays=trays))
+            units.append(LoadedSpoolUnit(id=ams_id, is_ams_ht=is_ams_ht_id(ams_id), trays=trays))
 
         external: list[LoadedSpoolTray] = []
         holders = [vt for vt in raw_data.get("vt_tray") or [] if isinstance(vt, dict)]
@@ -911,7 +924,7 @@ async def list_loaded_spools(
                 continue
             # The holders are trays 254 / 255; slot presets key them as AMS 255,
             # tray 0 / 1, the way the printer card saves them.
-            tray_id = int(vt.get("id", 254)) - 254
+            tray_id = external_slot_index(int(vt.get("id", 254)))
             external.append(_loaded_spool_tray(vt, 255, tray_id, saved.get((printer.id, 255, tray_id))))
 
         out.append(

+ 24 - 2
backend/app/api/routes/spoolbuddy.py

@@ -836,7 +836,12 @@ async def nfc_write_tag(
     Spoolman, so one with the same id must not stand in for the Spoolman spool.
     """
     from backend.app.models.spool import Spool
-    from backend.app.services.opentag3d import encode_opentag3d, encode_opentag3d_from_mapped
+    from backend.app.services.opentag3d import (
+        encode_opentag3d,
+        encode_opentag3d_from_mapped,
+        encode_opentag3d_v2,
+        encode_opentag3d_v2_from_mapped,
+    )
 
     # Find the device first (required regardless of spool source)
     result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == req.device_id))
@@ -854,6 +859,7 @@ async def nfc_write_tag(
         if spool is None:
             raise HTTPException(status_code=404, detail="Spool not found")
         ndef_data = encode_opentag3d(spool)
+        ndef_v2 = encode_opentag3d_v2(spool)
         data_origin = "local"
     else:
         from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
@@ -890,6 +896,7 @@ async def nfc_write_tag(
             )
 
         ndef_data = encode_opentag3d_from_mapped(mapped)
+        ndef_v2 = encode_opentag3d_v2_from_mapped(mapped)
         data_origin = "spoolman"
 
         # Warn when fields that drive NFC content are absent in Spoolman.
@@ -924,7 +931,12 @@ async def nfc_write_tag(
     device.pending_write_payload = json.dumps(
         {
             "spool_id": req.spool_id,
+            # OpenTag3D v1, which fits every NTAG; what a reader that knows
+            # nothing else writes.
             "ndef_data_hex": ndef_data.hex(),
+            # OpenTag3D 2.004, for a tag with room for it (NTAG215/216). The
+            # daemon picks by the tag's size (#3500).
+            "ndef_v2_hex": ndef_v2.hex(),
             "data_origin": data_origin,
         }
     )
@@ -1122,7 +1134,17 @@ async def nfc_write_result(
                 )
                 return {"status": "ok", "linked": False, "message": "Spool not found"}
 
-            spool.tag_uid = req.tag_uid.upper()
+            # The tag now holds this spool's data, so it moves here from any
+            # other spool that had it, as in the Spoolman branch above.
+            # Otherwise two spools share the UID and a scan finds the old one
+            # (#3505). Archived spools give it up as well, as link-tag does.
+            tag_uid = req.tag_uid.upper()
+            previous = await db.execute(select(Spool).where(func.upper(Spool.tag_uid) == tag_uid, Spool.id != spool.id))
+            for holder in previous.scalars().all():
+                holder.tag_uid = None
+                logger.info("Tag %s moved from spool %d to spool %d", tag_uid, holder.id, spool.id)
+
+            spool.tag_uid = tag_uid
             spool.tag_type = "ntag"
             spool.data_origin = "opentag3d"
             spool.encode_time = datetime.now(timezone.utc)

+ 7 - 2
backend/app/api/routes/spoolman.py

@@ -5,7 +5,7 @@ import logging
 from typing import Literal
 
 from fastapi import APIRouter, Depends, HTTPException
-from pydantic import BaseModel
+from pydantic import BaseModel, model_validator
 from sqlalchemy import delete, select, text
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
@@ -27,7 +27,7 @@ from backend.app.models.spool_assignment import SpoolAssignment
 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.spoolman import SlotAmsId, SlotTrayId
+from backend.app.schemas.spoolman import SlotAmsId, SlotTrayId, check_slot_tray_id
 from backend.app.services import slot_unlink_grace, spool_swaps
 from backend.app.services.ams_slot_presence import spool_present
 from backend.app.services.inventory_mode import spoolman_owns_assignments
@@ -858,6 +858,11 @@ class LinkSpoolRequest(BaseModel):
     ams_id: SlotAmsId | None = None
     tray_id: SlotTrayId | None = None
 
+    @model_validator(mode="after")
+    def a_real_slot(self) -> "LinkSpoolRequest":
+        check_slot_tray_id(self.ams_id, self.tray_id)
+        return self
+
 
 @router.post("/spools/{spool_id}/link")
 async def link_spool(

+ 15 - 2
backend/app/api/routes/spoolman_inventory.py

@@ -55,7 +55,13 @@ from backend.app.models.supplier import SpoolmanSpoolSupplier, Supplier
 from backend.app.models.user import User
 from backend.app.schemas.spool import SpoolFilamentPresetBase, SpoolKProfileBase, naive_utc
 from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
-from backend.app.schemas.spoolman import SlotAmsId, SlotTrayId, SpoolmanFilamentPatch, SpoolmanSlotAssignmentEnriched
+from backend.app.schemas.spoolman import (
+    SlotAmsId,
+    SlotTrayId,
+    SpoolmanFilamentPatch,
+    SpoolmanSlotAssignmentEnriched,
+    check_slot_tray_id,
+)
 from backend.app.schemas.supplier import SpoolSupplierLinkInput
 from backend.app.services import filament_profile_default as profile_defaults, slot_unlink_grace, spool_swaps
 from backend.app.services.ams_assignment_lock import get_spool_move_lock
@@ -86,6 +92,7 @@ from backend.app.utils.filament_ids import (
     normalize_slicer_filament,
 )
 from backend.app.utils.filament_types import nozzle_temp_range, printer_filament_type
+from backend.app.utils.tray_ids import external_tray_id
 
 logger = logging.getLogger(__name__)
 
@@ -506,6 +513,11 @@ class SpoolSlotAssignmentRequest(BaseModel):
     # transaction (#1574): a scanned spool is the one in the user's hand.
     move: bool = False
 
+    @model_validator(mode="after")
+    def a_real_slot(self) -> SpoolSlotAssignmentRequest:
+        check_slot_tray_id(self.ams_id, self.tray_id)
+        return self
+
 
 # ---------------------------------------------------------------------------
 # Endpoints
@@ -1720,6 +1732,7 @@ async def get_all_spoolman_slot_assignments(
                 tray_id=s.tray_id,
                 spoolman_spool_id=s.spoolman_spool_id,
                 ams_label=_ams_label_for(s.printer_id, s.ams_id),
+                printer_model=s.printer.model if s.printer else None,
             )
         )
     return enriched
@@ -1883,7 +1896,7 @@ def _live_slot_tray(state, ams_id: int, tray_id: int) -> dict:
     if ams_id == 255:
         vt_trays = raw.get("vt_tray") or []
         for vt in [vt_trays] if isinstance(vt_trays, dict) else vt_trays:
-            if isinstance(vt, dict) and str(vt.get("id", 254)) == str(254 + tray_id):
+            if isinstance(vt, dict) and str(vt.get("id", 254)) == str(external_tray_id(tray_id)):
                 return vt
         return {}
     units = raw.get("ams") or []

+ 42 - 34
backend/app/core/auth.py

@@ -9,6 +9,7 @@ from dataclasses import dataclass, field
 from datetime import datetime, timedelta, timezone
 from typing import Annotated
 
+import anyio
 import jwt
 from fastapi import Depends, Header, HTTPException, status
 from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
@@ -1244,41 +1245,48 @@ async def stream_opener_still_allowed(opener: StreamOpener, printer_id: int) ->
     from backend.app.services.long_lived_tokens import _is_expired
 
     try:
-        async with async_session() as db:
-            auth_on = await is_auth_enabled(db)
-            if not opener.auth_on:
-                return not auth_on
-            if not auth_on:
-                return True
-            if opener.long_lived_id is not None:
-                record = await db.get(LongLivedToken, opener.long_lived_id)
-                if (
-                    record is None
-                    or record.revoked_at is not None
-                    or record.user_id != opener.long_lived_user_id
-                    or record.lookup_prefix != opener.long_lived_prefix
-                    or _is_expired(record, datetime.now(timezone.utc))
-                    or not await user_id_holds(db, record.user_id, Permission.CAMERA_VIEW, record.created_at)
-                ):
-                    return False
-                scope = await resolve_user_id_printer_scope(db, record.user_id)
-            elif opener.api_key_id is not None:
-                if await usable_api_key_prefix(db, opener.api_key_id) != opener.api_key_prefix:
-                    return False
-                if not await principal_holds(db, None, opener.api_key_id, Permission.CAMERA_VIEW):
-                    return False
-                scope = await principal_printer_scope(db, None, opener.api_key_id)
-            elif opener.username:
-                user = await get_user_by_username(db, opener.username)
-                if user is None or not user.is_active or not user.has_permission(Permission.CAMERA_VIEW.value):
-                    return False
-                changed = user.password_changed_at
-                if changed is not None and changed.replace(tzinfo=None) > opener.started_at:
+        # Shielded (#3515): the stream ends when its viewer leaves, and a read
+        # cancelled halfway costs its pooled connection. The short check runs
+        # to the end first; the video stream itself is never shielded.
+        with anyio.CancelScope(shield=True):
+            async with async_session() as db:
+                auth_on = await is_auth_enabled(db)
+                if not opener.auth_on:
+                    return not auth_on
+                if not auth_on:
+                    return True
+                if opener.long_lived_id is not None:
+                    record = await db.get(LongLivedToken, opener.long_lived_id)
+                    if (
+                        record is None
+                        or record.revoked_at is not None
+                        or record.user_id != opener.long_lived_user_id
+                        or record.lookup_prefix != opener.long_lived_prefix
+                        or _is_expired(record, datetime.now(timezone.utc))
+                        or not await user_id_holds(db, record.user_id, Permission.CAMERA_VIEW, record.created_at)
+                    ):
+                        return False
+                    scope = await resolve_user_id_printer_scope(db, record.user_id)
+                elif opener.api_key_id is not None:
+                    if await usable_api_key_prefix(db, opener.api_key_id) != opener.api_key_prefix:
+                        return False
+                    if not await principal_holds(db, None, opener.api_key_id, Permission.CAMERA_VIEW):
+                        return False
+                    scope = await principal_printer_scope(db, None, opener.api_key_id)
+                elif opener.username:
+                    user = await get_user_by_username(db, opener.username)
+                    if user is None or not user.is_active or not user.has_permission(Permission.CAMERA_VIEW.value):
+                        return False
+                    changed = user.password_changed_at
+                    if changed is not None and changed.replace(tzinfo=None) > opener.started_at:
+                        return False
+                    scope = await principal_printer_scope(db, opener.username, None)
+                else:
                     return False
-                scope = await principal_printer_scope(db, opener.username, None)
-            else:
-                return False
-            return scope.allows(printer_id)
+                return scope.allows(printer_id)
+        # Unreachable: nothing cancels this scope itself, so it never swallows
+        # the return above. Never an answer of "allowed" if it ever did.
+        return False
     except Exception:  # SEC-AUTH-EXC: logged and re-raised, never an answer
         logger.warning("Camera stream re-check failed", exc_info=True)
         raise

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

+ 164 - 32
backend/app/core/database.py

@@ -14,6 +14,50 @@ from backend.app.core.db_dialect import is_sqlite
 logger = logging.getLogger(__name__)
 
 
+def _make_aiosqlite_stop_idempotent() -> None:
+    """Make aiosqlite's ``Connection.stop()`` safe to call twice (#3515).
+
+    aiosqlite 0.22.1 queues a stop request for the connection's worker thread
+    on every call, and the thread exits at the first. A second call's future
+    is then never resolved, and whoever awaits it waits forever. SQLAlchemy
+    reaches this on a cancelled read: it discards the connection, and when
+    that close is cancelled in turn it calls ``stop()`` while the close is
+    still running. The close never ends, its session's exit waits on it, a
+    pooled connection is lost, and in a camera stream the cancelled wait spun
+    one CPU core at 100%.
+
+    Upstream: omnilib/aiosqlite#403, fix in #404. Once a release has it, this
+    stays harmless (a second call returns the first call's future either way)
+    and can go.
+
+    aiosqlite before 0.22.1 has no ``stop()``, and SQLAlchemy then never calls
+    one, so there is nothing to fix. requirements.txt still allows those
+    versions, and the in-app update keeps an installed one.
+    """
+    try:
+        from aiosqlite.core import Connection
+    except ImportError:  # PostgreSQL-only environment
+        return
+    original = getattr(Connection, "stop", None)
+    if original is None or getattr(original, "_bambuddy_idempotent", False):
+        return
+
+    @functools.wraps(original)
+    def stop(self):
+        try:
+            return self.__dict__["_bambuddy_stop_future"]
+        except KeyError:
+            future = original(self)
+            self.__dict__["_bambuddy_stop_future"] = future
+            return future
+
+    stop._bambuddy_idempotent = True  # type: ignore[attr-defined]
+    Connection.stop = stop
+
+
+_make_aiosqlite_stop_idempotent()
+
+
 def _set_sqlite_pragmas(dbapi_conn, connection_record):
     """Set SQLite pragmas on each new connection for concurrency and performance."""
     cursor = dbapi_conn.cursor()
@@ -632,6 +676,7 @@ async def init_db():
         finance,
         github_backup,
         group,
+        hms_notified_fault,
         kprofile_note,
         library,
         local_preset,
@@ -1677,32 +1722,41 @@ async def _migrate_update_auto_link_constraint(conn) -> None:
 
 
 async def _migrate_widen_spoolman_slot_ams_id_range(conn) -> None:
-    """Widen ck_ams_id_range on spoolman_slot_assignments to admit AMS-HT (#1274).
+    """Bring spoolman_slot_assignments' CHECKs up to the model's (AMS-HT #1274, the H3C's AMS 3H).
 
-    Old formula: (ams_id >= 0 AND ams_id <= 7) OR ams_id = 255
-    New formula: (ams_id >= 0 AND ams_id <= 7) OR (ams_id >= 128 AND ams_id <= 191) OR ams_id = 255
+    ck_ams_id_range, oldest formula: (ams_id >= 0 AND ams_id <= 7) OR ams_id = 255
+    #1274 added AMS-HT:  ... OR (ams_id >= 128 AND ams_id <= 191) OR ams_id = 255
+    Now also the AMS 3H: ... OR ams_id = 32 OR ...
+    ck_tray_id_range:    tray_id 0-3, and 4-8 for ams_id 32 / 255 (models.spoolman_slot_assignment)
 
-    The H2C/H2D AMS-HT reports ams_id 128+. The old constraint rejected every
-    AMS-HT slot link with `IntegrityError: CHECK constraint failed: ck_ams_id_range`.
+    The H2C/H2D AMS-HT reports ams_id 128+. The H3C's AMS 3H is unit 32 with
+    nine slots, and its per-hotend external inputs are stored as (255, 2-8).
+    The old constraints rejected every such slot link with
+    `IntegrityError: CHECK constraint failed`. A table whose tray check is a
+    plain ``tray_id <= 8`` gets the final form too, without any row on slot 4-8
+    of another unit (no AMS has such a slot).
 
     PostgreSQL: DROP CONSTRAINT IF EXISTS + ADD new formula via _safe_execute.
-    SQLite: table recreation when the old (narrower) formula is detected in
-    sqlite_master. Fresh installs already have the widened constraint from
-    the CREATE TABLE migration above.
+    SQLite: table recreation when an older formula is detected in
+    sqlite_master. Fresh installs get the model's constraints from create_all.
     """
     from sqlalchemy import text
 
-    _NEW_FORMULA = "(ams_id >= 0 AND ams_id <= 7) OR (ams_id >= 128 AND ams_id <= 191) OR ams_id = 255"
-    _CONSTRAINT_NAME = "ck_ams_id_range"
+    from backend.app.models.spoolman_slot_assignment import AMS_ID_CHECK, TRAY_ID_CHECK
 
     if not is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE spoolman_slot_assignments DROP CONSTRAINT IF EXISTS ck_ams_id_range")
         await _safe_execute(
-            conn,
-            f"ALTER TABLE spoolman_slot_assignments DROP CONSTRAINT IF EXISTS {_CONSTRAINT_NAME}",
+            conn, f"ALTER TABLE spoolman_slot_assignments ADD CONSTRAINT ck_ams_id_range CHECK ({AMS_ID_CHECK})"
+        )
+        await _safe_execute(conn, "ALTER TABLE spoolman_slot_assignments DROP CONSTRAINT IF EXISTS ck_tray_id_range")
+        dropped = await conn.execute(
+            text(f"DELETE FROM spoolman_slot_assignments WHERE NOT ({TRAY_ID_CHECK})")  # nosec B608 -- the model's constant CHECK text
         )
+        if dropped.rowcount:
+            logger.warning("Dropped %d Spoolman slot assignment(s) naming a slot no AMS has", dropped.rowcount)
         await _safe_execute(
-            conn,
-            f"ALTER TABLE spoolman_slot_assignments ADD CONSTRAINT {_CONSTRAINT_NAME} CHECK ({_NEW_FORMULA})",
+            conn, f"ALTER TABLE spoolman_slot_assignments ADD CONSTRAINT ck_tray_id_range CHECK ({TRAY_ID_CHECK})"
         )
         return
 
@@ -1714,8 +1768,8 @@ async def _migrate_widen_spoolman_slot_ams_id_range(conn) -> None:
     if not row:
         return
     sql = row[0] or ""
-    # Already widened by an earlier run or by the fresh-install CREATE TABLE above.
-    if "ams_id >= 128" in sql:
+    # Already in the final form (a previous start, or create_all on a fresh install).
+    if "ams_id = 32" in sql and "ams_id IN (32, 255)" in sql:
         return
     # Pre-migration table without any CHECK constraint at all → leave alone;
     # the app-level validation handles correctness and we don't risk a
@@ -1731,29 +1785,38 @@ async def _migrate_widen_spoolman_slot_ams_id_range(conn) -> None:
                     "CREATE TABLE spoolman_slot_assignments_v2 ("
                     "id INTEGER PRIMARY KEY AUTOINCREMENT, "
                     "printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE, "
-                    f"ams_id INTEGER NOT NULL CHECK ({_NEW_FORMULA}), "
-                    "tray_id INTEGER NOT NULL CHECK (tray_id >= 0 AND tray_id <= 3), "
+                    "ams_id INTEGER NOT NULL, "
+                    "tray_id INTEGER NOT NULL, "
                     "spoolman_spool_id INTEGER NOT NULL, "
                     "assigned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "
-                    "CONSTRAINT uq_slot_assignment UNIQUE(printer_id, ams_id, tray_id)"
+                    "CONSTRAINT uq_slot_assignment UNIQUE(printer_id, ams_id, tray_id), "
+                    f"CONSTRAINT ck_ams_id_range CHECK ({AMS_ID_CHECK}), "
+                    f"CONSTRAINT ck_tray_id_range CHECK ({TRAY_ID_CHECK})"
                     ")"
                 )
             )
             await conn.execute(
                 text(
-                    "INSERT INTO spoolman_slot_assignments_v2 "
+                    "INSERT INTO spoolman_slot_assignments_v2 "  # nosec B608 -- the model's constant CHECK text
                     "(id, printer_id, ams_id, tray_id, spoolman_spool_id, assigned_at) "
                     "SELECT id, printer_id, ams_id, tray_id, spoolman_spool_id, assigned_at "
-                    "FROM spoolman_slot_assignments"
+                    f"FROM spoolman_slot_assignments WHERE {TRAY_ID_CHECK}"
                 )
             )
             original = (await conn.execute(text("SELECT count(*) FROM spoolman_slot_assignments"))).scalar_one()
+            kept = (
+                await conn.execute(
+                    text(f"SELECT count(*) FROM spoolman_slot_assignments WHERE {TRAY_ID_CHECK}")  # nosec B608 -- the model's constant CHECK text
+                )
+            ).scalar_one()
             copied = (await conn.execute(text("SELECT count(*) FROM spoolman_slot_assignments_v2"))).scalar_one()
-            if copied != original:
+            if copied != kept:
                 raise RuntimeError(
                     f"spoolman_slot_assignments migration: row count mismatch after copy "
-                    f"({original} in source, {copied} in copy)"
+                    f"({kept} to keep in source, {copied} in copy)"
                 )
+            if original != kept:
+                logger.warning("Dropped %d Spoolman slot assignment(s) naming a slot no AMS has", original - kept)
             await conn.execute(text("DROP TABLE spoolman_slot_assignments"))
             await conn.execute(text("ALTER TABLE spoolman_slot_assignments_v2 RENAME TO spoolman_slot_assignments"))
             # The index sits on the renamed table; recreate it idempotently
@@ -4692,18 +4755,21 @@ async def _run_migrations(conn):
     # Migration: Create spoolman_slot_assignments table for local AMS-slot→Spoolman-spool mapping.
     # Replaces the pattern of writing spool.location in Spoolman (which polluted the
     # user-editable storage_location field in the UI).
-    # ck_ams_id_range formula was widened in #1274 to admit AMS-HT (ams_id 128-191).
+    # ck_ams_id_range formula was widened in #1274 to admit AMS-HT (ams_id 128-191),
+    # and for the H3C's AMS 3H (as models.spoolman_slot_assignment).
     await _safe_execute(
         conn,
         """
         CREATE TABLE IF NOT EXISTS spoolman_slot_assignments (
             id INTEGER PRIMARY KEY AUTOINCREMENT,
             printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE,
-            ams_id INTEGER NOT NULL CHECK ((ams_id >= 0 AND ams_id <= 7) OR (ams_id >= 128 AND ams_id <= 191) OR ams_id = 255),
-            tray_id INTEGER NOT NULL CHECK (tray_id >= 0 AND tray_id <= 3),
+            ams_id INTEGER NOT NULL,
+            tray_id INTEGER NOT NULL,
             spoolman_spool_id INTEGER NOT NULL,
             assigned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
-            CONSTRAINT uq_slot_assignment UNIQUE(printer_id, ams_id, tray_id)
+            CONSTRAINT uq_slot_assignment UNIQUE(printer_id, ams_id, tray_id),
+            CONSTRAINT ck_ams_id_range CHECK ((ams_id >= 0 AND ams_id <= 7) OR ams_id = 32 OR (ams_id >= 128 AND ams_id <= 191) OR ams_id = 255),
+            CONSTRAINT ck_tray_id_range CHECK (tray_id >= 0 AND (tray_id <= 3 OR (ams_id IN (32, 255) AND tray_id <= 8)))
         )
         """
         if is_sqlite()
@@ -4711,11 +4777,13 @@ async def _run_migrations(conn):
         CREATE TABLE IF NOT EXISTS spoolman_slot_assignments (
             id SERIAL PRIMARY KEY,
             printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE,
-            ams_id INTEGER NOT NULL CHECK ((ams_id >= 0 AND ams_id <= 7) OR (ams_id >= 128 AND ams_id <= 191) OR ams_id = 255),
-            tray_id INTEGER NOT NULL CHECK (tray_id >= 0 AND tray_id <= 3),
+            ams_id INTEGER NOT NULL,
+            tray_id INTEGER NOT NULL,
             spoolman_spool_id INTEGER NOT NULL,
             assigned_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-            CONSTRAINT uq_slot_assignment UNIQUE(printer_id, ams_id, tray_id)
+            CONSTRAINT uq_slot_assignment UNIQUE(printer_id, ams_id, tray_id),
+            CONSTRAINT ck_ams_id_range CHECK ((ams_id >= 0 AND ams_id <= 7) OR ams_id = 32 OR (ams_id >= 128 AND ams_id <= 191) OR ams_id = 255),
+            CONSTRAINT ck_tray_id_range CHECK (tray_id >= 0 AND (tray_id <= 3 OR (ams_id IN (32, 255) AND tray_id <= 8)))
         )
         """,
     )
@@ -4725,8 +4793,8 @@ async def _run_migrations(conn):
     )
 
     # Migration: widen ck_ams_id_range on spoolman_slot_assignments to allow
-    # AMS-HT ids (128-191). Existing installs created before #1274 carry the
-    # stale formula which rejects every AMS-HT slot link with a CHECK violation.
+    # AMS-HT ids (128-191) and the H3C's AMS 3H (32, nine slots). Existing
+    # installs carry a stale formula which rejects such slot links.
     await _migrate_widen_spoolman_slot_ams_id_range(conn)
 
     # Migration: Create spoolman_k_profile table for K-value calibration profiles linked to Spoolman spools.
@@ -5872,6 +5940,8 @@ async def _run_migrations(conn):
         await conn.execute(
             text("UPDATE printers SET camera_light_auto = :off WHERE camera_light_auto IS NULL"), {"off": False}
         )
+    # Migration: optional per-printer WLED preset configuration (#1528).
+    await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN wled_config JSON")
 
     # Migration: printer-scoped groups (#1727). Defaults off, so no existing
     # group narrows anyone's printers on upgrade; the group_printers table
@@ -6038,6 +6108,7 @@ async def _run_migrations(conn):
     await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN plate_estimates JSON")
 
     await _backfill_archive_run_photos(conn)
+    await _migrate_h3c_queue_tray_ids(conn)
 
     await _migrate_auth_datetimes_to_timestamp(conn)
     await _migrate_add_missing_set_null_fks(conn)
@@ -6090,6 +6161,67 @@ async def _backfill_print_log_plate_ids(conn) -> None:
         await conn.execute(text('INSERT INTO settings ("key", value) VALUES (:k, :v)'), {"k": flag, "v": "true"})
 
 
+async def _migrate_h3c_queue_tray_ids(conn) -> None:
+    """Renumber the AMS 3H slots of queued H3C jobs from 128 + k to Studio's 28 + k.
+
+    Bambuddy without H3C support numbered the AMS 3H (unit 32) as
+    ``32 * 4 + k``, the AMS-HT range, so an H3C job queued then carries 128-136
+    where the slot is now 28-36. Only jobs for an H3C (its printer, or
+    target_model H3C) are touched; the H3C has no AMS-HT, so 128-136 there can
+    only mean that. Once only, gated by a settings flag, so a mapping written
+    afterwards is never rewritten.
+    """
+    import json
+
+    from sqlalchemy import text
+
+    from backend.app.utils.tray_ids import AMS3H_UNIT_ID, RACK_SLOT_COUNT, TrayLayout, legacy_tray_index
+
+    old_base = legacy_tray_index(AMS3H_UNIT_ID, 0)  # 128
+    ams3h = TrayLayout.ams3h()
+    flag = "_migrate_h3c_queue_tray_ids_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:
+            return
+        targets = (
+            (
+                "print_queue",
+                "q.ams_mapping IS NOT NULL AND (UPPER(q.target_model) IN ('H3C', 'N10') "
+                "OR q.printer_id IN (SELECT id FROM printers WHERE UPPER(model) IN ('H3C', 'N10')))",
+            ),
+            ("print_queue_variants", "q.ams_mapping IS NOT NULL AND UPPER(q.target_model) IN ('H3C', 'N10')"),
+        )
+        renumbered = 0
+        for table, where in targets:
+            query = f"SELECT q.id, q.ams_mapping FROM {table} q WHERE {where}"  # nosec B608 -- table and filter from the fixed targets above
+            rows = (await conn.execute(text(query))).all()
+            for row_id, raw in rows:
+                try:
+                    mapping = json.loads(raw)
+                except (TypeError, ValueError):
+                    continue
+                if not isinstance(mapping, list):
+                    continue
+                new = [
+                    ams3h.global_tray_id(AMS3H_UNIT_ID, v - old_base)
+                    if type(v) is int and old_base <= v < old_base + RACK_SLOT_COUNT
+                    else v
+                    for v in mapping
+                ]
+                if new != mapping:
+                    await conn.execute(
+                        text(f"UPDATE {table} SET ams_mapping = :m WHERE id = :id"),  # nosec B608 -- table from the fixed targets above
+                        {"m": json.dumps(new), "id": row_id},
+                    )
+                    renumbered += 1
+        if renumbered:
+            logger.info("Renumbered the AMS 3H slots of %d queued H3C job(s) to Bambu Studio's 28-36", renumbered)
+        await conn.execute(text('INSERT INTO settings ("key", value) VALUES (:k, :v)'), {"k": flag, "v": "true"})
+
+
 async def _backfill_archive_run_photos(conn) -> None:
     """Put back on its archive a finish photo only its run kept.
 

+ 3 - 0
backend/app/core/sqlite_fk.py

@@ -93,6 +93,9 @@ APPLIED: frozenset[tuple[str, str]] = frozenset(
         ("slicer_pipelines", "target_printer_id"),
         # A deleted printer's spools would stay "assigned" to it (SA-1).
         ("spool_assignment", "printer_id"),
+        # A printer given a deleted one's id would never be told about the
+        # faults that printer was notified about (#3486).
+        ("hms_notified_faults", "printer_id"),
         # A profile default's K profile would select a calibration slot on
         # the next printer given the id (#1326).
         ("filament_profile_default_k_profile", "printer_id"),

Разница между файлами не показана из-за своего большого размера
+ 610 - 2
backend/app/data/hms_catalog.json


+ 145 - 59
backend/app/main.py

@@ -23,6 +23,7 @@ from fastapi.staticfiles import StaticFiles
 from sqlalchemy import delete, func, inspect as sa_inspect, or_, select, text, update
 from sqlalchemy.engine import CursorResult
 from sqlalchemy.exc import IntegrityError
+from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm.exc import StaleDataError
 
 from backend.app.api.routes import (
@@ -132,6 +133,7 @@ from backend.app.services.camera_light import camera_light
 from backend.app.services.energy_plug import energy_plug_candidates, select_energy_reading
 from backend.app.services.github_backup import github_backup_service
 from backend.app.services.ha_sensor_manager import ha_sensor_manager
+from backend.app.services.hms_errors import hms_fault_counts as _hms_fault_counts
 from backend.app.services.library_trash import library_trash_service
 from backend.app.services.local_backup import local_backup_service
 from backend.app.services.location_ha_sensor_manager import location_ha_sensor_manager
@@ -157,6 +159,7 @@ from backend.app.services.printer_manager import (
     parse_plate_id,
     printer_manager,
     printer_state_to_dict,
+    printer_tray_layout,
     resolve_plate_id,
 )
 from backend.app.services.slot_kprofile import find_slot_kprofile_for_extruder
@@ -177,12 +180,14 @@ from backend.app.services.spoolman_tracking import (
     store_print_data as _store_spoolman_print_data,
 )
 from backend.app.services.telegram_reactions import telegram_reaction_poller
+from backend.app.services.wled import wled_manager
 from backend.app.utils.ams_drying import is_drying_active, temperature_alarm_suppressed
 from backend.app.utils.ams_humidity import ams_humidity_percent
 from backend.app.utils.filament_types import printer_filament_type
 from backend.app.utils.fts_routing import extruder_for_inlet
 from backend.app.utils.local_time import to_naive_utc, utcnow_naive
 from backend.app.utils.print_jobs import is_internal_printer_job
+from backend.app.utils.tray_ids import AmsType, TrayLayout, external_tray_id, unit_ams_type, unit_label
 
 
 # =============================================================================
@@ -1805,7 +1810,8 @@ def _extract_filament_data_from_mqtt(data: dict, ams_mapping: list[int] | None =
     from backend.app.services.print_scheduler import _global_tray_id
 
     # Map global tray id → (type, color), addressed as the print's ams_mapping
-    # is: unit * 4 + tray, or the bare unit id for an AMS-HT (from 128).
+    # is (utils.tray_ids).
+    layout = TrayLayout.from_units(ams_units)
     loaded: dict[int, tuple[str, str]] = {}
     for unit in ams_units:
         if not isinstance(unit, dict):
@@ -1828,7 +1834,7 @@ def _extract_filament_data_from_mqtt(data: dict, ams_mapping: list[int] | None =
             tcolor = f"#{hex_color}" if re.fullmatch(r"[0-9A-F]{6}", hex_color) else ""
             if not ttype:
                 continue  # Empty / unloaded slot.
-            loaded[_global_tray_id(unit_id, tray_id)] = (ttype, tcolor)
+            loaded[_global_tray_id(unit_id, tray_id, layout)] = (ttype, tcolor)
 
     if not loaded:
         return result
@@ -2020,24 +2026,6 @@ def _hms_notify_key(error) -> str:
     return f"{error.attr:08X}:{error.code}"
 
 
-def _hms_fault_counts(error) -> bool:
-    """Whether a fault counts as a problem: the same rule the frontend's
-    ``filterKnownHMSErrors`` applies to the printer card, badge and camera wall.
-
-    It counts when Bambu publishes text for it or it offers action buttons, and
-    its level is a real one. An ``hms[]`` fault at level 3 (notification) with
-    no actions does not count: those are things like "the top cover is open" or
-    "the chamber is hot, fan speed increased", which a printer can hold through
-    a whole print. A ``print_error`` at the same level (0xCxxx) still counts, as
-    it always has; those are prompts such as "unable to start drying" (#2728).
-    """
-    if error.severity < 1:
-        return False
-    has_actions = bool(getattr(error, "actions", None))
-    is_hms_notice = len(getattr(error, "full_code", "") or "") == 16 and error.severity == 3
-    return has_actions or (bool(getattr(error, "description", None)) and not is_hms_notice)
-
-
 def _hms_errors_to_notify(errors: list, new_error_codes: set[str]) -> list:
     """The new faults that count (see ``_hms_fault_counts``).
 
@@ -2049,6 +2037,30 @@ def _hms_errors_to_notify(errors: list, new_error_codes: set[str]) -> list:
     return [e for e in errors if _hms_notify_key(e) in new_error_codes and _hms_fault_counts(e)]
 
 
+# What each HMS module byte is about, read off Bambu's own texts for its codes
+# (hms_catalog.json). The table used to call 0x05 "Nozzle/Extruder", 0x0C
+# "Motion Controller" and 0x12 "Chamber", which titled USB and MQTT errors as
+# nozzle faults and an A1 mini's AMS Lite runouts as chamber errors (#3486).
+_HMS_MODULE_NAMES = {
+    0x03: "Print/Task",  # bed, nozzle temperatures, motors, the print itself
+    0x05: "System",  # USB/SD, network, firmware, files, AMS communication
+    0x07: "AMS/Filament",
+    0x08: "Toolhead",
+    0x0C: "Camera",  # cameras, lidar, first-layer and AI inspection
+    0x12: "AMS Lite",
+    0x18: "AMS-HT",
+    0x1A: "Tool Changer",
+    0x29: "Air Filter",
+    0x2A: "AMS 3H",
+}
+
+
+def _hms_error_title(module: int) -> str:
+    """The notification title for a fault of *module*."""
+    name = _HMS_MODULE_NAMES.get(module)
+    return f"{name} Error" if name else "Printer Error"
+
+
 def _take_new_hms_faults(printer_id: int, errors: list, print_state: str | None = None) -> list:
     """The faults on this printer not notified yet, and record them as notified.
 
@@ -2092,6 +2104,47 @@ def _take_new_hms_faults(printer_id: int, errors: list, print_state: str | None
     return _hms_errors_to_notify(errors, new)
 
 
+async def _take_new_hms_faults_and_save(printer_id: int, errors: list, print_state: str | None = None) -> list:
+    """``_take_new_hms_faults``, and store the notified set when it changed (#3486).
+
+    The set lives in memory, so without this every restart sent each fault the
+    printer still lists once more. A failed write only costs that protection
+    for the next restart, so it is logged and the notification goes ahead.
+    """
+    before = set(_notified_hms_errors.get(printer_id, ()))
+    new = _take_new_hms_faults(printer_id, errors, print_state)
+    if set(_notified_hms_errors.get(printer_id, ())) != before:
+        from backend.app.services.hms_notified import save_notified_hms_faults
+
+        try:
+            await save_notified_hms_faults(printer_id, lambda: _notified_hms_errors.get(printer_id, {}).keys())
+        except Exception as e:
+            logging.getLogger(__name__).warning(
+                "Could not store the notified HMS faults of printer %s: %s", printer_id, e
+            )
+    return new
+
+
+async def _load_notified_hms_faults() -> None:
+    """Restore the faults notified before the restart (#3486).
+
+    Each counts as seen now: one still listed is not sent again, and one that
+    is gone is forgotten after the usual grace period.
+    """
+    from backend.app.services.hms_notified import load_notified_hms_faults
+
+    try:
+        loaded = await load_notified_hms_faults()
+    except Exception as e:
+        logging.getLogger(__name__).warning("Could not load the notified HMS faults: %s", e)
+        return
+    now = time.time()
+    for printer_id, keys in loaded.items():
+        seen = _notified_hms_errors.setdefault(printer_id, {})
+        for key in keys:
+            seen.setdefault(key, now)
+
+
 # `stg_cur` values that mean the printer is not in a preparation stage: 0 is
 # "Printing", -1 and 255 are "no stage" (#3211).
 _NO_PREPARATION_STAGES = frozenset({0, -1, 255})
@@ -2148,6 +2201,11 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
         pause_eta.observe(printer_id, state)
     except Exception:
         logging.getLogger(__name__).exception("Pause heads-up check failed for printer %s", printer_id)
+    wled_manager.handle_status(
+        printer_id,
+        state,
+        awaiting_plate_clear=printer_manager.is_awaiting_plate_clear(printer_id),
+    )
     # Connected-edge reconciliation (#1542 follow-up). When the printer
     # transitions disconnected → connected — which covers both Bambuddy
     # startup (no prior connection) and a mid-session MQTT reconnect — fire
@@ -2547,7 +2605,7 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
     # Check for new HMS errors and send notifications
     current_hms_errors = getattr(state, "hms_errors", []) or []
     if current_hms_errors:
-        new_errors = _take_new_hms_faults(printer_id, current_hms_errors, state.state)
+        new_errors = await _take_new_hms_faults_and_save(printer_id, current_hms_errors, state.state)
 
         if new_errors:
             try:
@@ -2561,16 +2619,6 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
 
                 printer_name = printer.name if printer else f"Printer {printer_id}"
 
-                # Format error details for notification
-                # Module 0x07 = AMS/Filament, 0x05 = Nozzle, 0x0C = Motion Controller, etc.
-                module_names = {
-                    0x03: "Print/Task",
-                    0x05: "Nozzle/Extruder",
-                    0x07: "AMS/Filament",
-                    0x0C: "Motion Controller",
-                    0x12: "Chamber",
-                }
-
                 # Capture camera snapshot once for all error notifications (no DB held).
                 error_image_data = await _capture_snapshot_for_notification(
                     printer_id, printer, logging.getLogger(__name__)
@@ -2580,7 +2628,6 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
                 async with async_session() as db:
                     sent_count = 0
                     for error in new_errors:
-                        module_name = module_names.get(error.module, f"Module 0x{error.module:02X}")
                         # Build short code like "0700_8010"
                         # Mask to 16 bits to handle printers that send larger values
                         error_code_int = int(error.code.replace("0x", ""), 16) if error.code else 0
@@ -2595,7 +2642,7 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
                         if not description or short_code in _HMS_NOTIFICATION_SUPPRESS:
                             continue
 
-                        error_type = f"{module_name} Error"
+                        error_type = _hms_error_title(error.module)
                         error_detail = description
 
                         await notification_service.on_printer_error(
@@ -2632,7 +2679,7 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
         # No HMS errors: nothing to send, but faults gone long enough (or gone
         # across a print state change) are forgotten. Some codes, e.g. chamber
         # temperature regulation during PETG prints, toggle every few seconds.
-        _take_new_hms_faults(printer_id, [], state.state)
+        await _take_new_hms_faults_and_save(printer_id, [], state.state)
 
     await ws_manager.send_printer_status(
         printer_id,
@@ -2746,12 +2793,7 @@ def _slot_feeds_print(printer_id: int, ams_id: int, tray_id: int) -> bool:
     status = printer_manager.get_status(printer_id)
     if status is None:
         return False
-    if ams_id == 255:
-        global_id = 254 + tray_id
-    elif ams_id >= 128:
-        global_id = ams_id
-    else:
-        global_id = ams_id * 4 + tray_id
+    global_id = printer_tray_layout(printer_id, printer_manager).tray_from_slot_key(ams_id, tray_id)
     if getattr(status, "tray_now", None) == global_id:
         return True
     slots = getattr(status, "extruder_slots", None) or {}
@@ -2806,7 +2848,7 @@ async def _unlink_stale_assignments(printer_id: int, ams_data: list, printing_no
                 if assignment.ams_id == 255:
                     ps = printer_manager.get_status(printer_id)
                     vt_tray_raw = ps.raw_data.get("vt_tray", []) if ps else []
-                    ext_id = assignment.tray_id + 254  # 0→254, 1→255
+                    ext_id = external_tray_id(assignment.tray_id)  # 0→254, 1→255, rack 2-8→247-253
                     current_tray = None
                     for vt in vt_tray_raw:
                         if isinstance(vt, dict) and int(vt.get("id", 254)) == ext_id:
@@ -4649,7 +4691,7 @@ async def _decide_outcome_for_external_print(db, printer_id: int, observed_name:
 
     expected = await _queue_item_dispatched_name(db, dispatched_here)
     observed = (observed_name or "").strip()
-    if not expected or not observed or _subtask_names_match(expected, observed):
+    if not expected or not observed or _dispatched_name_matches_echo(expected, observed):
         logger.info(
             "[CALLBACK] Not asking for the outcome on printer %s: queue item %s is still printing, so "
             "Bambuddy dispatched this run and the item's own ask-for-outcome flag decides.",
@@ -8244,8 +8286,16 @@ async def reconcile_stale_active_prints(printer_id: int) -> int:
         if outcome != "aborted":
             # The printer's progress and layer are this job's, so a partial
             # run is charged what it printed rather than nothing.
-            if state.progress:
-                payload["last_progress"] = state.progress
+            from backend.app.utils.printer_models import usage_progress
+
+            run_progress = usage_progress(
+                printer_manager.get_model(printer_id),
+                state.progress,
+                getattr(state, "layer_num", None),
+                getattr(state, "total_layers", None),
+            )
+            if run_progress:
+                payload["last_progress"] = run_progress
             if state.layer_num:
                 payload["last_layer_num"] = state.layer_num
         if outcome == "failed":
@@ -8953,6 +9003,21 @@ def _subtask_names_match(expected: str, observed: str) -> bool:
     return False
 
 
+def _dispatched_name_matches_echo(expected: str, observed: str) -> bool:
+    """Whether the printer's subtask echo is the name a print was dispatched as.
+
+    A dispatched print is echoed under its bare name at start, but a printer
+    that picks a print back up -- Bambuddy reconnecting to a print already
+    running, or an A1 mini resuming after power-loss recovery -- can report the
+    file name with its extension instead (#3485). The echo is tried both as it
+    came and with the extensions stripped; the raw form still has to count, so
+    a model named ``Foo.3mf`` (file ``Foo.3mf.gcode.3mf``) keeps matching.
+    """
+    return _subtask_names_match(expected, observed) or _subtask_names_match(
+        expected, _subtask_name_from_filename(observed)
+    )
+
+
 async def _queue_item_dispatched_name(db, item) -> str:
     """The subtask name *item* was dispatched under, or "" when unknowable.
 
@@ -8997,7 +9062,7 @@ async def _completion_belongs_to_queue_item(db, item, data: dict) -> bool:
         return True
 
     expected = _subtask_name_from_filename(archive.filename)
-    if not expected or _subtask_names_match(expected, observed):
+    if not expected or _dispatched_name_matches_echo(expected, observed):
         return True
 
     logging.getLogger(__name__).warning(
@@ -10795,8 +10860,12 @@ async def _close_out_print(printer_id: int, data: dict):
 
     log_timing("All background tasks scheduled")
 
-    # Auto-scan for timelapse if recording was active during the print
-    if archive_id and data.get("timelapse_was_active") and data.get("status") == "completed":
+    # Auto-scan for timelapse if recording was active during the print. A
+    # failed or cancelled print gets one too: the printer writes the video of
+    # the run so far when it stops (measured on an H2C, #3481), and that video
+    # is how the user sees what went wrong. A model that writes none costs one
+    # poll that finds nothing new.
+    if archive_id and data.get("timelapse_was_active"):
         logger.info("[TIMELAPSE] Timelapse was active during print, scheduling auto-scan for archive %s", archive_id)
         # Schedule timelapse scan as background task with retries
         # The printer needs time to encode the video after print completion
@@ -11123,11 +11192,9 @@ async def record_ams_history():
                         recorded_count += 1
 
                         # Generate AMS label and determine if it's AMS-HT (A, B, C, D or HT-A for AMS-Lite/Hub)
-                        is_ams_ht = ams_id >= 128
-                        if is_ams_ht:
-                            ams_label = f"HT-{chr(65 + (ams_id - 128))}"
-                        else:
-                            ams_label = f"AMS-{chr(65 + ams_id)}"
+                        ams_type = unit_ams_type(ams_id, ams_data.get("info"))
+                        is_ams_ht = ams_type == AmsType.N3S
+                        ams_label = unit_label(ams_id, ams_type)
 
                         # Skip alarm dispatch for empty AMS units — humidity /
                         # temperature readings are ambient with no filament to
@@ -11944,6 +12011,19 @@ def stop_auth_cleanup() -> None:
         logging.getLogger(__name__).info("Auth periodic cleanup stopped")
 
 
+async def initialize_wled_printers(db: AsyncSession) -> None:
+    """Seed WLED settings and apply offline presets before MQTT connections start."""
+    from backend.app.api.routes.settings import get_setting, setting_is_true
+    from backend.app.models.printer import Printer
+
+    wled_manager.require_plate_clear = setting_is_true(await get_setting(db, "require_plate_clear"))
+    result = await db.execute(select(Printer.id, Printer.wled_config, Printer.is_active))
+    for printer_id, config, is_active in result.all():
+        wled_manager.configure_printer(printer_id, config)
+        if not is_active and config:
+            wled_manager.handle_offline(printer_id)
+
+
 @asynccontextmanager
 async def lifespan(app: FastAPI):
     # Startup
@@ -12057,6 +12137,14 @@ async def lifespan(app: FastAPI):
 
     # Rehydrate persisted awaiting-plate-clear gate (#961) so prompts survive restarts
     await printer_manager.load_awaiting_plate_clear_from_db()
+    # Before any printer connects, or its first report re-sends every fault
+    # still listed (#3486).
+    await _load_notified_hms_faults()
+
+    # WLED is entirely optional. Seed its in-memory per-printer configuration
+    # before MQTT connections begin emitting status callbacks.
+    async with async_session() as db:
+        await initialize_wled_printers(db)
 
     # Layer change callback for external camera timelapse
     async def on_layer_change(printer_id: int, layer_num: int):
@@ -12219,13 +12307,9 @@ async def lifespan(app: FastAPI):
                 _slot_label_from_global_tray,
             )
 
-            if ams_id == 255:
-                global_id = 254 + tray_id
-            elif ams_id >= 128:
-                global_id = ams_id
-            else:
-                global_id = ams_id * 4 + tray_id
-            slot_label = _slot_label_from_global_tray(global_id)
+            layout = printer_tray_layout(printer_id, printer_manager)
+            global_id = layout.tray_from_slot_key(ams_id, tray_id)
+            slot_label = _slot_label_from_global_tray(global_id, layout)
 
             printer_info = printer_manager.get_printer(printer_id)
             printer_name = printer_info.name if printer_info else f"Printer {printer_id}"
@@ -12508,6 +12592,8 @@ async def lifespan(app: FastAPI):
 
     await mqtt_relay.disconnect(timeout=2)
 
+    await wled_manager.shutdown()
+
     # Drop the shared Bambu Cloud HTTP client we registered at startup.
     set_shared_http_client(None)
     set_shared_makerworld_http_client(None)

+ 29 - 0
backend/app/models/hms_notified_fault.py

@@ -0,0 +1,29 @@
+"""HMS faults a notification has already gone out for, per printer (#3486).
+
+The printer keeps faults in its ``hms[]`` list long after they happened, and
+the de-duplication that stops them being sent again lived only in memory: every
+Bambuddy restart sent each still-listed fault once more. Kept here so a restart
+picks up where it left off. Only the set of fault keys is stored; when each was
+last seen stays in memory.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class HmsNotifiedFault(Base):
+    __tablename__ = "hms_notified_faults"
+    __table_args__ = (UniqueConstraint("printer_id", "fault_key", name="uq_hms_notified_faults_printer_fault"),)
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    printer_id: Mapped[int] = mapped_column(
+        Integer, ForeignKey("printers.id", ondelete="CASCADE"), nullable=False, index=True
+    )
+    fault_key: Mapped[str] = mapped_column(String(64), nullable=False)
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())

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

@@ -145,6 +145,17 @@ DEFAULT_TEMPLATES = [
             "so raise the threshold or dry the spools off the printer."
         ),
     },
+    {
+        "event_type": "ams_drying_not_heating",
+        "name": "Auto-Drying Not Heating",
+        "title_template": "Auto-Drying Suspended",
+        "body_template": (
+            "{printer} {ams_label}: stopped automatic drying. The printer accepted {cycles} drying commands "
+            "in a row, but each time the drying timer stood still and the unit did not heat. Clear the timer "
+            "with Stop on the printer card and check the AMS power; automatic drying resumes once humidity "
+            "is back at or below {threshold}%."
+        ),
+    },
     {
         "event_type": "bed_cooled",
         "name": "Bed Cooled",

+ 3 - 1
backend/app/models/printer.py

@@ -1,6 +1,6 @@
 from datetime import datetime
 
-from sqlalchemy import Boolean, DateTime, Float, String, false, func
+from sqlalchemy import JSON, Boolean, DateTime, Float, String, false, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from backend.app.core.database import Base
@@ -54,6 +54,8 @@ class Printer(Base):
     # Queue: True after a print finishes/fails, until user acknowledges the plate is cleared.
     # Persisted so the gate survives crashes and power cycles (issue #961).
     awaiting_plate_clear: Mapped[bool] = mapped_column(Boolean, default=False)
+    # Optional per-printer WLED preset mapping. WLED owns all visual effects.
+    wled_config: Mapped[dict | None] = mapped_column(JSON, 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())
 

+ 5 - 0
backend/app/models/spool_assignment.py

@@ -30,6 +30,11 @@ class SpoolAssignment(Base):
         """Get printer name from loaded relationship."""
         return self.printer.name if self.printer else None
 
+    @property
+    def printer_model(self) -> str | None:
+        """Printer model from the loaded relationship (lets the UI label an H3C's per-hotend external inputs)."""
+        return self.printer.model if self.printer else None
+
 
 from backend.app.models.printer import Printer  # noqa: E402, F401
 from backend.app.models.spool import Spool  # noqa: E402, F401

+ 10 - 9
backend/app/models/spoolman_slot_assignment.py

@@ -5,6 +5,10 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from backend.app.core.database import Base
 
+AMS_ID_CHECK = "(ams_id >= 0 AND ams_id <= 7) OR ams_id = 32 OR (ams_id >= 128 AND ams_id <= 191) OR ams_id = 255"
+# Slots 0-3; 4-8 only on the AMS 3H (32) and among an H3C's external inputs (255).
+TRAY_ID_CHECK = "tray_id >= 0 AND (tray_id <= 3 OR (ams_id IN (32, 255) AND tray_id <= 8))"
+
 
 class SpoolmanSlotAssignment(Base):
     """Assignment of a Spoolman spool to a specific AMS slot on a printer.
@@ -27,15 +31,12 @@ class SpoolmanSlotAssignment(Base):
 
     __table_args__ = (
         UniqueConstraint("printer_id", "ams_id", "tray_id", name="uq_slot_assignment"),
-        # 0-7: standard AMS units. 128-191: AMS-HT (each unit uses ams_id 128+,
-        # single tray). 255: external / VT tray. Matches the value range the
-        # internal `spool_assignment` table accepts. See #1274 — H2C with
-        # AMS-HT on the left nozzle reports ams_id=128.
-        CheckConstraint(
-            "(ams_id >= 0 AND ams_id <= 7) OR (ams_id >= 128 AND ams_id <= 191) OR ams_id = 255",
-            name="ck_ams_id_range",
-        ),
-        CheckConstraint("tray_id >= 0 AND tray_id <= 3", name="ck_tray_id_range"),
+        # 0-7: standard AMS units. 32: the H3C's AMS 3H. 128-191: AMS-HT (each
+        # unit uses ams_id 128+, single tray). 255: external / VT tray. Matches
+        # the value range the internal `spool_assignment` table accepts. See
+        # #1274 — H2C with AMS-HT on the left nozzle reports ams_id=128.
+        CheckConstraint(AMS_ID_CHECK, name="ck_ams_id_range"),
+        CheckConstraint(TRAY_ID_CHECK, name="ck_tray_id_range"),
     )
 
 

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

@@ -26,6 +26,7 @@ class EventType(StrEnum):
     AMS_HUMIDITY_HIGH = "ams_humidity_high"
     AMS_TEMPERATURE_HIGH = "ams_temperature_high"
     AMS_DRYING_SUSPENDED = "ams_drying_suspended"
+    AMS_DRYING_NOT_HEATING = "ams_drying_not_heating"
     BED_COOLED = "bed_cooled"
     HA_SENSOR_ALERT = "ha_sensor_alert"
     TEST = "test"
@@ -122,6 +123,15 @@ EVENT_VARIABLES: dict[str, list[str]] = {
         "timestamp",
         "app_name",
     ],
+    "ams_drying_not_heating": [
+        "printer",
+        "ams_label",
+        "humidity",
+        "threshold",
+        "cycles",
+        "timestamp",
+        "app_name",
+    ],
     "bed_cooled": ["printer", "bed_temp", "threshold", "filename", "timestamp", "app_name"],
     "ha_sensor_alert": ["printer", "sensor", "state", "timestamp", "app_name"],
     "location_ha_sensor_alert": ["location", "sensor", "state", "timestamp", "app_name"],
@@ -353,6 +363,15 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "timestamp": "2024-01-15 14:30",
         "app_name": "Bambuddy",
     },
+    "ams_drying_not_heating": {
+        "printer": "Bambu P2S",
+        "ams_label": "HT-A",
+        "humidity": "75",
+        "threshold": "60",
+        "cycles": "2",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "Bambuddy",
+    },
     "bed_cooled": {
         "printer": "Bambu X1C",
         "bed_temp": "34",

+ 92 - 2
backend/app/schemas/printer.py

@@ -1,6 +1,7 @@
 from datetime import datetime
+from urllib.parse import urlparse
 
-from pydantic import BaseModel, Field, field_validator, model_validator
+from pydantic import BaseModel, Field, field_serializer, field_validator, model_validator
 
 from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
 from backend.app.schemas.printer_location import normalize_location_name
@@ -26,6 +27,83 @@ def _mask_url_login(url: str | None) -> str | None:
     return f"{url[:start]}{shown}@{host}{url[end:]}"
 
 
+class WLEDPresets(BaseModel):
+    idle: int | None = Field(default=None, ge=1, le=250)
+    prepare: int | None = Field(default=None, ge=1, le=250)
+    printing: int | None = Field(default=None, ge=1, le=250)
+    paused: int | None = Field(default=None, ge=1, le=250)
+    finished: int | None = Field(default=None, ge=1, le=250)
+    error: int | None = Field(default=None, ge=1, le=250)
+    queue_waiting: int | None = Field(default=None, ge=1, le=250)
+    filament_problem: int | None = Field(default=None, ge=1, le=250)
+    hms_error: int | None = Field(default=None, ge=1, le=250)
+    offline: int | None = Field(default=None, ge=1, le=250)
+
+
+class WLEDStoredConfig(BaseModel):
+    enabled: bool = False
+    base_url: str | None = None
+    presets: WLEDPresets = Field(default_factory=WLEDPresets)
+    finished_timeout_seconds: int | None = Field(default=None, ge=0, le=86400)
+
+    @field_serializer("base_url")
+    def _mask_stored_url_login(self, value: str | None) -> str | None:
+        return _mask_url_login(value)
+
+
+class WLEDConfig(WLEDStoredConfig):
+    base_url: str | None = Field(default=None, max_length=500)
+
+    @field_validator("base_url")
+    @classmethod
+    def _validate_base_url(cls, value: str | None) -> str | None:
+        if value is None:
+            return None
+        normalized = value.strip().rstrip("/")
+        if not normalized:
+            return None
+
+        from backend.app.api.routes._url_safety import assert_safe_lan_service_url
+
+        assert_safe_lan_service_url(normalized, label="WLED URL")
+        parsed = urlparse(normalized)
+        if parsed.username is not None or parsed.password is not None:
+            raise ValueError("WLED URL must not include credentials")
+        return normalized
+
+    @model_validator(mode="after")
+    def _require_base_url_when_enabled(self):
+        if self.enabled and not self.base_url:
+            raise ValueError("base_url is required when WLED is enabled")
+        return self
+
+
+class WLEDPreset(BaseModel):
+    id: int
+    name: str
+
+
+class WLEDPresetListRequest(BaseModel):
+    base_url: str = Field(max_length=500)
+
+    @field_validator("base_url")
+    @classmethod
+    def _validate_base_url(cls, value: str) -> str:
+        normalized = WLEDConfig._validate_base_url(value)
+        if not normalized:
+            raise ValueError("base_url is required")
+        return normalized
+
+
+class WLEDPresetTestRequest(WLEDPresetListRequest):
+    preset_id: int = Field(ge=1, le=250)
+
+
+class WLEDConnectionInfo(BaseModel):
+    name: str | None = None
+    version: str | None = None
+
+
 class PrinterBase(BaseModel):
     name: str = Field(..., min_length=1, max_length=100)
     serial_number: str = Field(..., min_length=1, max_length=50)
@@ -68,6 +146,9 @@ class PrinterCreate(PrinterBase):
     # PrinterResponse. Direct exposure on PRINTERS_READ would let a Viewer
     # connect to the printer's MQTT and bypass Bambuddy's RBAC.
     access_code: str = Field(..., min_length=1, max_length=20)
+    # Input shape validates the URL; PrinterResponse serialises stored rows
+    # with WLEDStoredConfig so one bad stored URL cannot break GET /printers.
+    wled_config: WLEDConfig | None = None
 
     # Input only, not on PrinterBase: SQLite never enforced the column width,
     # so a stored location may be longer, and the response must still read it.
@@ -108,9 +189,11 @@ class PrinterUpdate(IgnoresNullForRequiredColumns):
     camera_light_auto: bool | None = None  # #1655
     plate_detection_enabled: bool | None = None
     plate_detection_roi: PlateDetectionROI | None = None
+    wled_config: WLEDConfig | None = None
 
 
 class PrinterResponse(PrinterBase):
+    wled_config: WLEDStoredConfig | None = None
     id: int
     is_active: bool
     nozzle_count: int = 1  # 1 or 2, auto-detected from MQTT
@@ -247,7 +330,12 @@ class AMSUnit(BaseModel):
     dry_sf_reason: list[int] = []  # Cannot-dry reasons from firmware (see CannotDryReason)
     dry_target_temp: int | None = None  # Active-cycle target °C (Bambu doesn't echo this)
     dry_filament: str | None = None  # Active-cycle filament name we sent
-    module_type: str = ""  # "ams", "ams_f1", "n3f", "n3s"
+    module_type: str = ""  # "ams", "ams_f1", "n3f", "n3s", "n10a" (the H3C's AMS 3H)
+    ams_type: int | None = None  # Studio's AMS type (utils.tray_ids.AmsType); decides the global tray ids
+    # The AMS 3H's dryer: its drying zone's two slots and readings. Empty elsewhere.
+    dryer_slots: list[int] = []
+    dryer_humidity: int | None = None
+    dryer_temp: float | None = None
 
 
 class NozzleInfoResponse(BaseModel):
@@ -262,12 +350,14 @@ class NozzleRackSlot(BaseModel):
     nozzle_type: str = ""
     nozzle_diameter: str = ""
     wear: int | None = None
+    print_time_s: int | None = None  # H2C: seconds this nozzle has printed, from "p_t" (#1908)
     stat: int | None = None  # Nozzle status (e.g. mounted/docked)
     max_temp: int = 0  # Max temperature rating °C (0 = not set)
     serial_number: str = ""  # Nozzle serial number
     filament_color: str = ""  # RGBA hex ("00000000" = no filament)
     filament_id: str = ""  # Bambu filament ID
     filament_type: str = ""  # Material type (e.g. "PLA", "PETG")
+    addr_id: int | None = None  # H3C: home rack position 0-8 (the mounted hotend reports id 0)
 
 
 class AmsLabelBody(BaseModel):

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

@@ -356,6 +356,7 @@ class SpoolAssignmentResponse(BaseModel):
     configured: bool = False
     pending_config: bool = False  # True when slot was empty at assign time; will configure on insert
     ams_label: str | None = None  # User-defined friendly name for the AMS unit
+    printer_model: str | None = None  # e.g. "H3C": labels its AMS 3H slots "3H-n" and its external inputs "Ext-n"
 
     class Config:
         from_attributes = True

+ 22 - 7
backend/app/schemas/spoolman.py

@@ -2,19 +2,32 @@ from typing import Annotated
 
 from pydantic import AfterValidator, BaseModel, Field, model_validator
 
+from backend.app.utils.tray_ids import AMS3H_UNIT_ID, EXTERNAL_AMS_ID
+
 
 def _a_slot_ams_id(ams_id: int) -> int:
-    """The ams_ids spoolman_slot_assignments' CHECK admits: AMS units 0-7,
-    AMS-HT 128-191, the external slot 255. Checked on the request, so a write
-    to Spoolman that goes with the slot row is never made for a row the
-    database then refuses (SSA-9)."""
-    if 0 <= ams_id <= 7 or 128 <= ams_id <= 191 or ams_id == 255:
+    """The ams_ids spoolman_slot_assignments' CHECK admits: AMS units 0-7, the
+    AMS 3H 32, AMS-HT 128-191, the external slot 255. Checked on the request,
+    so a write to Spoolman that goes with the slot row is never made for a row
+    the database then refuses (SSA-9)."""
+    if 0 <= ams_id <= 7 or 128 <= ams_id <= 191 or ams_id in (AMS3H_UNIT_ID, EXTERNAL_AMS_ID):
         return ams_id
-    raise ValueError("ams_id must be 0-7 (AMS), 128-191 (AMS-HT) or 255 (external)")
+    raise ValueError("ams_id must be 0-7 (AMS), 32 (AMS 3H), 128-191 (AMS-HT) or 255 (external)")
 
 
 SlotAmsId = Annotated[int, AfterValidator(_a_slot_ams_id)]
-SlotTrayId = Annotated[int, Field(ge=0, le=3)]
+# 4-8 only for ams_id 32 / 255: a request with both fields also runs check_slot_tray_id.
+SlotTrayId = Annotated[int, Field(ge=0, le=8)]
+
+
+def check_slot_tray_id(ams_id: int | None, tray_id: int | None) -> None:
+    """Refuse slots 4-8 except on the AMS 3H (32) and the external inputs (255), as the table's CHECK does."""
+    if ams_id is None or tray_id is None or tray_id <= 3:
+        return
+    if ams_id not in (AMS3H_UNIT_ID, EXTERNAL_AMS_ID):
+        raise ValueError(
+            f"tray_id must be 0-3 for ams_id {ams_id}: only the AMS 3H (32) and the external inputs (255) have slots 4-8"
+        )
 
 
 class SpoolmanFilamentPatch(BaseModel):
@@ -44,3 +57,5 @@ class SpoolmanSlotAssignmentEnriched(BaseModel):
     tray_id: int
     spoolman_spool_id: int
     ams_label: str | None
+    # e.g. "H3C": labels its AMS 3H slots "3H-n" and its external inputs "Ext-n"
+    printer_model: str | None = None

+ 10 - 4
backend/app/services/archive_cost_estimate.py

@@ -36,10 +36,12 @@ from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.config import settings as app_settings
+from backend.app.utils.tray_ids import RACK_EXTERNAL_IDS
 
 logger = logging.getLogger(__name__)
 
-_EXTERNAL_TRAY_IDS = (254, 255)
+# Every external input id, 254/255 first (a rack printer's 247-253 after).
+_EXTERNAL_TRAY_IDS = (254, 255, *RACK_EXTERNAL_IDS[:-2])
 
 
 def _mapped_tray(slot_id: int, slot_to_tray: list, ams_trays: dict[int, dict]) -> int | None:
@@ -85,11 +87,13 @@ async def _internal_rates(
     """
     from backend.app.models.spool import Spool
     from backend.app.models.spool_assignment import SpoolAssignment
+    from backend.app.services.printer_manager import printer_tray_layout
     from backend.app.services.spoolman_tracking import _global_tray_id_to_ams_slot
 
     rates: dict[int, float] = {}
+    layout = printer_tray_layout(printer_id)
     for tray in trays:
-        ams_id, tray_id = _global_tray_id_to_ams_slot(tray)
+        ams_id, tray_id = _global_tray_id_to_ams_slot(tray, layout)
         spool = (
             await db.execute(
                 select(Spool)
@@ -115,6 +119,7 @@ async def _spoolman_rates(printer_id: int, trays: set[int], ams_trays: dict[int,
     slot assignment, #1459) and prices it with ``_spool_cost_per_gram``, so the
     estimate and the completion figure read the same price fields.
     """
+    from backend.app.services.printer_manager import printer_tray_layout
     from backend.app.services.spoolman import get_spoolman_client
     from backend.app.services.spoolman_tracking import (
         _get_printer_serial,
@@ -131,6 +136,7 @@ async def _spoolman_rates(printer_id: int, trays: set[int], ams_trays: dict[int,
     if client is None:
         return {}
     serial = await _get_printer_serial(printer_id)
+    layout = printer_tray_layout(printer_id)
 
     rates: dict[int, float] = {}
     for tray in trays:
@@ -138,11 +144,11 @@ async def _spoolman_rates(printer_id: int, trays: set[int], ams_trays: dict[int,
         try:
             tray_info = ams_trays.get(tray)
             if tray_info:
-                tag = _resolve_spool_tag(tray_info, serial, tray)
+                tag = _resolve_spool_tag(tray_info, serial, tray, layout)
                 if tag:
                     spool = await client.find_spool_by_tag(tag)
             if spool is None:
-                ams_id, tray_id = _global_tray_id_to_ams_slot(tray)
+                ams_id, tray_id = _global_tray_id_to_ams_slot(tray, layout)
                 spool_id = await _resolve_spool_id_via_slot_assignment(printer_id, ams_id, tray_id)
                 if spool_id is not None:
                     spool = await client.get_spool(spool_id)

+ 65 - 0
backend/app/services/backup_paths.py

@@ -0,0 +1,65 @@
+"""Make file paths from a Windows backup usable on Linux and macOS (#3501).
+
+Bambuddy stores file locations relative to its data folder, written with the
+platform's separator: a Windows install stores ``archive\\12\\print.3mf``.
+Windows reads either separator, Linux and macOS read the backslashes as part
+of one file name, so after a Windows backup is restored there every archive,
+library file and print log entry points at nothing.
+
+Runs once at the end of a restore, never at startup.
+"""
+
+import logging
+import os
+from pathlib import Path
+
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import AsyncConnection
+
+logger = logging.getLogger(__name__)
+
+# Columns holding a path relative to the data folder. Photos and project
+# attachments store bare file names; pending uploads and external folders
+# hold absolute host paths, which mean nothing on another machine anyway.
+PATH_COLUMNS: dict[str, tuple[str, ...]] = {
+    "print_archives": ("file_path", "thumbnail_path", "timelapse_path", "source_3mf_path", "f3d_path"),
+    "library_files": ("file_path", "thumbnail_path"),
+    "print_log_entries": ("thumbnail_path", "timelapse_path"),
+}
+
+
+async def convert_windows_paths(conn: AsyncConnection, base_dir: Path, *, windows: bool | None = None) -> int:
+    """Turn the backslashes in stored paths into slashes; returns the rows changed.
+
+    A path that exists as written is left alone: on Linux a backslash can be
+    part of a real file name. Nothing happens on Windows, where backslashes
+    are the separator.
+    """
+    if windows is None:
+        windows = os.name == "nt"
+    if windows:
+        return 0
+
+    changed = 0
+    for table, columns in PATH_COLUMNS.items():
+        # Filtered here rather than with LIKE: Postgres reads a backslash in a
+        # LIKE pattern as an escape, SQLite does not.
+        # SQL from the table and column names in PATH_COLUMNS only; values are bound.
+        rows = (await conn.execute(text(f"SELECT id, {', '.join(columns)} FROM {table}"))).all()  # nosec B608
+        for row in rows:
+            updates = {}
+            for column, value in zip(columns, row[1:], strict=True):
+                if not value or "\\" not in value:
+                    continue
+                # Only stat()ed, to decide whether to rewrite the row; nothing is read or written through it.
+                if (base_dir / value).exists():  # SEC-PATH-OK: stat only, see above
+                    continue
+                updates[column] = value.replace("\\", "/")
+            if updates:
+                assignments = ", ".join(f"{column} = :{column}" for column in updates)
+                # Column names from PATH_COLUMNS, values bound.
+                await conn.execute(text(f"UPDATE {table} SET {assignments} WHERE id = :id"), {**updates, "id": row[0]})  # nosec B608
+                changed += 1
+    if changed:
+        logger.info("Converted Windows file paths in %d restored row(s) to this platform's separator", changed)
+    return changed

Разница между файлами не показана из-за своего большого размера
+ 443 - 140
backend/app/services/bambu_mqtt.py


+ 6 - 5
backend/app/services/camera.py

@@ -188,7 +188,7 @@ def rtsp_socket_timeout_flag() -> str:
 def supports_rtsp(model: str | None) -> bool:
     """Check if printer model supports RTSP camera streaming.
 
-    RTSP supported: X1, X1C, X1E, X2D, H2C, H2D, H2DPRO, H2S, P2S
+    RTSP supported: X1, X1C, X1E, X2D, H2C, H2D, H2DPRO, H2S, H3C, P2S
     Chamber image only: A1, A1MINI, P1P, P1S
 
     Note: Model can be either display name (e.g., "P2S") or internal code (e.g., "N7").
@@ -201,14 +201,15 @@ def supports_rtsp(model: str | None) -> bool:
       - O1S: H2S
       - O1E, O2D: H2D Pro
       - N7: P2S
+      - N10: H3C
     """
     if model:
         model_upper = model.upper()
-        # Display names: X1, X1C, X1E, X2D, H2C, H2D, H2DPRO, H2S, P2S
-        if model_upper.startswith(("X1", "X2", "H2", "P2")):
+        # Display names: X1, X1C, X1E, X2D, H2C, H2D, H2DPRO, H2S, H3C, P2S
+        if model_upper.startswith(("X1", "X2", "H2", "H3", "P2")):
             return True
         # Internal codes for RTSP models
-        if model_upper in ("BL-P001", "C13", "N6", "O1D", "O1C", "O1C2", "O1S", "O1E", "O2D", "N7"):
+        if model_upper in ("BL-P001", "C13", "N6", "O1D", "O1C", "O1C2", "O1S", "O1E", "O2D", "N7", "N10"):
             return True
     # A1/P1 and unknown models use chamber image protocol
     return False
@@ -217,7 +218,7 @@ def supports_rtsp(model: str | None) -> bool:
 def get_camera_port(model: str | None) -> int:
     """Get the camera port based on printer model.
 
-    X1/X2/H2/P2 series use RTSP on port 322.
+    X1/X2/H2/H3/P2 series use RTSP on port 322.
     A1/P1 series use chamber image protocol on port 6000.
     """
     if supports_rtsp(model):

+ 10 - 3
backend/app/services/camera_light.py

@@ -24,6 +24,8 @@ import time
 from collections.abc import AsyncIterator
 from contextlib import asynccontextmanager
 
+import anyio
+
 logger = logging.getLogger(__name__)
 
 # Long enough to cover a viewer reconnecting and the camera wall polling each
@@ -56,9 +58,14 @@ async def _read_settings() -> tuple[str, float]:
     from backend.app.api.routes.settings import get_setting
     from backend.app.core import database
 
-    async with database.async_session() as db:
-        mode = await get_setting(db, "camera_light_mode")
-        delay = await get_setting(db, "camera_light_delay")
+    # Shielded (#3515): a viewer leaving cancels the stream, and a read
+    # cancelled halfway costs its pooled connection. The two short reads and
+    # the session's close run to the end first.
+    mode = delay = None
+    with anyio.CancelScope(shield=True):
+        async with database.async_session() as db:
+            mode = await get_setting(db, "camera_light_mode")
+            delay = await get_setting(db, "camera_light_delay")
     mode = mode if mode in _MODES else "off"
     try:
         seconds = float(delay) if delay is not None else 2.0

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

@@ -225,3 +225,72 @@ def apply_design_overrides(process_json: str, overrides: list[DesignOverride], s
     process_cfg.update(by_key)
     logger.info("Carrying %d design setting(s) onto the picked process preset: %s", len(by_key), sorted(by_key))
     return json.dumps(process_cfg)
+
+
+def changed_process_keys(before_json: str, after_json: str) -> list[str]:
+    """The keys Bambuddy set on a process JSON between two points (#3497).
+
+    Added keys and keys whose value changed. Empty when either side does not
+    parse as a JSON object, so a caller then records nothing rather than a
+    guess.
+    """
+    try:
+        before = json.loads(before_json)
+        after = json.loads(after_json)
+    except json.JSONDecodeError:
+        return []
+    if not isinstance(before, dict) or not isinstance(after, dict):
+        return []
+    return sorted(key for key, value in after.items() if key not in before or before[key] != value)
+
+
+def record_process_changes(zip_bytes: bytes, keys: list[str]) -> bytes:
+    """List *keys* as changed from the system process preset in a sliced 3MF (#3497).
+
+    The slicer CLI writes ``different_settings_to_system`` but never fills it,
+    so the settings Bambuddy put on the picked preset are in the file without
+    being marked. Bambu Studio then opens the project with the stock preset's
+    values for them, and a re-slice there drops them.
+
+    Merges into the process entry (index 0) only: keys already listed stay,
+    the filament and printer entries are left alone. Returns the bytes
+    unchanged when there is nothing to add or the file is not one whose layout
+    we understand, the same checks :func:`overrides_from_config` reads it with.
+    """
+    if not keys:
+        return zip_bytes
+    try:
+        with zipfile.ZipFile(BytesIO(zip_bytes), "r") as zin:
+            if _PROJECT_SETTINGS not in zin.namelist():
+                return zip_bytes
+            config = json.loads(zin.read(_PROJECT_SETTINGS).decode("utf-8"))
+            if not isinstance(config, dict):
+                return zip_bytes
+            changed = config.get("different_settings_to_system")
+            filaments = config.get("filament_settings_id")
+            if (
+                not isinstance(changed, list)
+                or not isinstance(filaments, list)
+                or len(changed) != len(filaments) + 2
+                or not isinstance(changed[0], str)
+            ):
+                logger.info("Sliced 3MF has no usable different_settings_to_system; process changes not recorded")
+                return zip_bytes
+            listed = _split_changed_keys(changed[0])
+            merged = sorted(set(listed) | set(keys))
+            if merged == sorted(listed):
+                return zip_bytes
+            changed[0] = ";".join(merged)
+            patched = json.dumps(config, indent=4)
+
+            dst = BytesIO()
+            with zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zout:
+                for item in zin.infolist():
+                    if item.filename == _PROJECT_SETTINGS:
+                        zout.writestr(item, patched)
+                    else:
+                        zout.writestr(item, zin.read(item.filename))
+    except (zipfile.BadZipFile, json.JSONDecodeError, UnicodeDecodeError, OSError):
+        return zip_bytes
+    logger.info("Recorded %d process setting(s) as changed from the system preset", len(keys))
+    return dst.getvalue()

+ 8 - 26
backend/app/services/filament_deficit.py

@@ -44,7 +44,9 @@ from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 from backend.app.services.filament_requirements import extract_filament_requirements
+from backend.app.services.printer_manager import printer_tray_layout
 from backend.app.utils.filament_ids import normalize_slicer_filament
+from backend.app.utils.tray_ids import TrayLayout
 
 logger = logging.getLogger(__name__)
 
@@ -71,29 +73,6 @@ class FilamentDeficit:
         }
 
 
-def _global_to_ams_key(global_tray_id: int) -> tuple[int, int]:
-    """Inverse of ``ams_id * 4 + tray_id`` — matches ``usage_tracker``."""
-    if global_tray_id >= 254:
-        return (255, global_tray_id - 254)
-    if global_tray_id >= 128:
-        return (global_tray_id, 0)
-    return (global_tray_id // 4, global_tray_id % 4)
-
-
-def _ams_key_to_global(ams_id: int, tray_id: int) -> int:
-    """Inverse of ``_global_to_ams_key``.
-
-    Mirrors the frontend ``getGlobalTrayId``: external / VT slots (``ams_id``
-    255) land at ``254 + tray_id``, AMS-HT units (``ams_id`` >= 128) use the
-    unit id directly, regular AMS slots use ``ams_id * 4 + tray_id``.
-    """
-    if ams_id >= 255:
-        return 254 + tray_id
-    if ams_id >= 128:
-        return ams_id
-    return ams_id * 4 + tray_id
-
-
 def _resolve_source_3mf(item: PrintQueueItem) -> Path | None:
     """Locate the 3MF file backing this queue item (archive or library).
 
@@ -296,6 +275,7 @@ def _live_tray_keys(printer_id: int) -> dict[int, str]:
     units = raw.get("ams") or []
     if isinstance(units, dict):  # also stored as the push's {"ams": [...]}
         units = units.get("ams") or []
+    layout = TrayLayout.from_state(state)
     for unit in units:
         if not isinstance(unit, dict):
             continue
@@ -306,7 +286,7 @@ def _live_tray_keys(printer_id: int) -> dict[int, str]:
                 ams_id, tray_id = int(unit.get("id", "")), int(tray.get("id", ""))
             except (TypeError, ValueError):
                 continue
-            _add(_ams_key_to_global(ams_id, tray_id), tray)
+            _add(layout.tray_from_slot_key(ams_id, tray_id), tray)
     vt_trays = raw.get("vt_tray") or []
     for tray in [vt_trays] if isinstance(vt_trays, dict) else vt_trays:
         if not isinstance(tray, dict):
@@ -547,6 +527,7 @@ async def build_slot_materials(
     # so a status push in between cannot put them in different pools.
     if live_keys is None:
         live_keys = _live_tray_keys(printer_id)
+    layout = printer_tray_layout(printer_id)
     materials: list[SlotMaterial] = []
 
     def _append(
@@ -557,7 +538,7 @@ async def build_slot_materials(
         spool: SlotSpoolIdentity | None = None,
         cost_per_kg: float | None = None,
     ) -> None:
-        global_tray_id = _ams_key_to_global(ams_id, tray_id)
+        global_tray_id = layout.tray_from_slot_key(ams_id, tray_id)
         materials.append(
             SlotMaterial(
                 ams_id=ams_id,
@@ -733,6 +714,7 @@ async def compute_deficit_for_queue_item(
     spoolman_mode = await _is_spoolman_mode(db)
     backup_on, ams_extruder_map, is_dual = await _get_printer_backup_context(printer_id)
     live_keys = _live_tray_keys(printer_id) if backup_on else {}
+    layout = printer_tray_layout(printer_id)
 
     # ------------------------------------------------------------------ phase 1
     # Resolve each requirement to (ams_id, tray_id, identity, remaining_grams).
@@ -784,7 +766,7 @@ async def compute_deficit_for_queue_item(
         if global_tray_id is None or global_tray_id < 0:
             _undetermined(req)
             continue
-        ams_id, tray_id = _global_to_ams_key(global_tray_id)
+        ams_id, tray_id = layout.slot_key(global_tray_id)
 
         identity = "attrs:|||"
         remaining: float | None = None

+ 15 - 0
backend/app/services/firmware_check.py

@@ -67,6 +67,7 @@ MODEL_TO_API_KEY = {
     "H2D": "h2d",
     "H2C": "h2c",
     "H2S": "h2s",
+    "H3C": "h3c",
     "P2S": "p2s",
     "X1E": "x1e",
     "X2D": "x2d",
@@ -91,6 +92,7 @@ MODEL_TO_API_KEY = {
     "N6": "x2d",
     "N7": "p2s",
     "N9": "a2l",
+    "N10": "h3c",
 }
 
 # Reverse mapping: API key to model codes
@@ -107,6 +109,7 @@ API_KEY_TO_DEV_MODEL = {
     "x2d": "N6",
     "h2d-pro": "O1E",
     "a2l": "N9",
+    "h3c": "N10",
 }
 
 # Wiki firmware release history pages (primary version source)
@@ -126,6 +129,8 @@ API_KEY_TO_WIKI_PATH = {
     # _fetch_all_versions_from_wiki silently returns [] on 404 so this is safe
     # to ship before Bambu publishes the page.
     "a2l": "/en/a2l/manual/a2l-firmware-release-history",
+    # H3C: no model prefix in the page name, like the H2D Pro.
+    "h3c": "/en/h3c/manual/firmware-release-history",
 }
 
 
@@ -386,6 +391,16 @@ class FirmwareCheckService:
             if versions:
                 return versions
 
+            # H3C-style headings, "OTA 01.01.03.00" with id="ota-01010300":
+            # no release date on the page.
+            for a, b, c, d in re.findall(r'id="ota-(\d{2})(\d{2})(\d{2})(\d{2})"', response.text):
+                v = f"{a}.{b}.{c}.{d}"
+                if v not in seen:
+                    seen.add(v)
+                    versions.append((v, None))
+            if versions:
+                return versions
+
             # Fallback: heading text with "XX.XX.XX.XX (YYYYMMDD)" —
             # accept both ASCII "()" and full-width "()" (U+FF08/U+FF09)
             # which some pages (A1, A1-mini, P2S) use.

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

@@ -90,3 +90,21 @@ def alert_level_from_print_error(error: int) -> int:
     way. 0 for anything else, which Bambu defines as an invalid level.
     """
     return {0x4: 1, 0x8: 2, 0xC: 3}.get((error >> 12) & 0xF, 0)
+
+
+def hms_fault_counts(error) -> bool:
+    """Whether a fault counts as a problem: the same rule the frontend's
+    ``filterKnownHMSErrors`` applies to the printer card, badge and camera wall.
+
+    It counts when Bambu publishes text for it or it offers action buttons, and
+    its level is a real one. An ``hms[]`` fault at level 3 (notification) with
+    no actions does not count: those are things like "the top cover is open" or
+    "the chamber is hot, fan speed increased", which a printer can hold through
+    a whole print. A ``print_error`` at the same level (0xCxxx) still counts, as
+    it always has; those are prompts such as "unable to start drying" (#2728).
+    """
+    if error.severity < 1:
+        return False
+    has_actions = bool(getattr(error, "actions", None))
+    is_hms_notice = len(getattr(error, "full_code", "") or "") == 16 and error.severity == 3
+    return has_actions or (bool(getattr(error, "description", None)) and not is_hms_notice)

+ 58 - 0
backend/app/services/hms_notified.py

@@ -0,0 +1,58 @@
+"""Persistence for the HMS faults already notified (#3486).
+
+The notification de-duplication itself stays in ``main._take_new_hms_faults``,
+in memory. This only stores which fault keys it holds, so a restart does not
+send every fault a printer still lists again.
+"""
+
+import asyncio
+import logging
+
+from sqlalchemy import delete, select
+
+from backend.app.core.database import async_session
+from backend.app.models.hms_notified_fault import HmsNotifiedFault
+
+logger = logging.getLogger(__name__)
+
+# One writer at a time per printer, so the last write is the latest set.
+_locks: dict[int, asyncio.Lock] = {}
+
+
+async def load_notified_hms_faults() -> dict[int, set[str]]:
+    """Every printer's notified fault keys, as stored."""
+    async with async_session() as db:
+        rows = (await db.execute(select(HmsNotifiedFault.printer_id, HmsNotifiedFault.fault_key))).all()
+    loaded: dict[int, set[str]] = {}
+    for printer_id, fault_key in rows:
+        loaded.setdefault(printer_id, set()).add(fault_key)
+    return loaded
+
+
+async def save_notified_hms_faults(printer_id: int, current_keys) -> None:
+    """Store *current_keys()* as the printer's notified faults.
+
+    *current_keys* is called once the lock is held, so a write that waited
+    stores the set as it is then, not as it was when the write was asked for.
+    """
+    lock = _locks.setdefault(printer_id, asyncio.Lock())
+    async with lock:
+        keys = set(current_keys())
+        async with async_session() as db:
+            stored = set(
+                (
+                    await db.execute(
+                        select(HmsNotifiedFault.fault_key).where(HmsNotifiedFault.printer_id == printer_id)
+                    )
+                ).scalars()
+            )
+            gone = stored - keys
+            if gone:
+                await db.execute(
+                    delete(HmsNotifiedFault).where(
+                        HmsNotifiedFault.printer_id == printer_id, HmsNotifiedFault.fault_key.in_(gone)
+                    )
+                )
+            for key in keys - stored:
+                db.add(HmsNotifiedFault(printer_id=printer_id, fault_key=key))
+            await db.commit()

+ 5 - 4
backend/app/services/kprofile_drift.py

@@ -28,6 +28,7 @@ from backend.app.services.slot_kprofile import find_slot_kprofile_for_extruder
 from backend.app.services.slot_nozzle import resolve_slot_nozzle
 from backend.app.services.spool_filament_preset import printer_safe_filament_id
 from backend.app.utils.printer_models import is_dual_nozzle_model
+from backend.app.utils.tray_ids import EXTERNAL_AMS_ID, TrayLayout, external_slot_index
 
 logger = logging.getLogger(__name__)
 
@@ -110,6 +111,7 @@ def _loaded_trays(state) -> list[tuple[int, int, int, dict]]:
     raw = getattr(state, "raw_data", None) or {}
     ams_raw = raw.get("ams")
     units = ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
+    layout = TrayLayout.from_state(state)
     trays: list[tuple[int, int, int, dict]] = []
     for unit in units:
         if not isinstance(unit, dict):
@@ -125,8 +127,7 @@ def _loaded_trays(state) -> list[tuple[int, int, int, dict]]:
                 tray_id = int(tray.get("id", 0))
             except (TypeError, ValueError):
                 continue
-            global_id = ams_id if ams_id >= 128 else ams_id * 4 + tray_id
-            trays.append((ams_id, tray_id, global_id, tray))
+            trays.append((ams_id, tray_id, layout.global_tray_id(ams_id, tray_id), tray))
 
     vt_raw = raw.get("vt_tray")
     if isinstance(vt_raw, dict):
@@ -138,8 +139,8 @@ def _loaded_trays(state) -> list[tuple[int, int, int, dict]]:
             vt_id = int(vt.get("id", 254))
         except (TypeError, ValueError):
             continue
-        if vt_id in (254, 255):
-            trays.append((255, vt_id - 254, vt_id, vt))
+        if layout.is_external(vt_id):
+            trays.append((EXTERNAL_AMS_ID, external_slot_index(vt_id), vt_id, vt))
     return trays
 
 

+ 29 - 3
backend/app/services/log_reader.py

@@ -25,6 +25,7 @@ from backend.app.core.logging_filters import (
 from backend.app.models.printer import Printer
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
+from backend.app.utils.printer_models import PRINTER_MODEL_ID_MAP, PRINTER_MODEL_MAP
 
 logger = logging.getLogger(__name__)
 
@@ -239,6 +240,29 @@ def sanitize_log_content(content: str, sensitive_strings: dict[str, str] | None
     return content
 
 
+def _model_key(value: str) -> str:
+    return value.upper().replace(" ", "").replace("-", "")
+
+
+# Every model name and code Bambuddy knows. A printer named after its model
+# keeps nothing private in that name, and redacting it would wipe the model
+# from the whole support package (#3442).
+_MODEL_NAME_KEYS = frozenset(
+    _model_key(v)
+    for v in (*PRINTER_MODEL_MAP, *PRINTER_MODEL_MAP.values(), *PRINTER_MODEL_ID_MAP, *PRINTER_MODEL_ID_MAP.values())
+)
+
+
+def _is_model_name(name: str, model: str | None) -> bool:
+    """True when the printer name is just a model name, so not worth redacting.
+
+    The printer's own model counts too, which covers a model Bambuddy does not
+    list yet.
+    """
+    key = _model_key(name.strip())
+    return key in _MODEL_NAME_KEYS or (bool(model) and key == _model_key(model.strip()))
+
+
 async def collect_sensitive_strings(db: AsyncSession) -> dict[str, str]:
     """Collect known sensitive values from the database for log redaction.
 
@@ -249,9 +273,11 @@ async def collect_sensitive_strings(db: AsyncSession) -> dict[str, str]:
     sensitive_strings: dict[str, str] = {}
 
     # Printer names, serial numbers, IP addresses, and access codes
-    result = await db.execute(select(Printer.name, Printer.serial_number, Printer.ip_address, Printer.access_code))
-    for name, serial, ip_address, access_code in result.all():
-        if name:
+    result = await db.execute(
+        select(Printer.name, Printer.serial_number, Printer.ip_address, Printer.access_code, Printer.model)
+    )
+    for name, serial, ip_address, access_code, model in result.all():
+        if name and not _is_model_name(name, model):
             sensitive_strings[name] = "[PRINTER]"
         if serial:
             sensitive_strings[serial] = "[SERIAL]"

+ 11 - 3
backend/app/services/notification_service.py

@@ -31,6 +31,7 @@ from backend.app.services.email_service import add_required_headers
 from backend.app.services.notify_client import NotifyClient, NotifyError, notify_credentials, notify_supports_photos
 from backend.app.services.print_confirmation import one_tap_url
 from backend.app.utils.notification_photos import save_notification_photo
+from backend.app.utils.tray_ids import EXTERNAL_AMS_ID, is_ams_ht_id, unit_label
 
 logger = logging.getLogger(__name__)
 
@@ -1929,10 +1930,10 @@ class NotificationService:
                     tray_id = u.get("tray_id", 0)
                     material = u.get("material", "Unknown") or "Unknown"
                     used = u.get("weight_used", 0)
-                    if ams_id >= 128:
+                    if ams_id == EXTERNAL_AMS_ID or is_ams_ht_id(ams_id):
                         slot_label = "Ext"
                     else:
-                        slot_label = f"AMS-{chr(65 + ams_id)} T{tray_id + 1}"
+                        slot_label = f"{unit_label(ams_id)} T{tray_id + 1}"
                     parts.append(f"{slot_label} {material}: {used:.1f}g")
                 variables["filament_details"] = " | ".join(parts)
             elif archive_data.get("filament_slots"):
@@ -2428,9 +2429,15 @@ class NotificationService:
         threshold: float,
         cycles: int,
         db: AsyncSession,
+        *,
+        never_heated: bool = False,
     ):
         """Handle automatic drying giving up on one AMS unit (#2770).
 
+        ``never_heated`` sends the message for a unit whose printer accepted
+        the drying commands but never heated (#3478). Same provider switch:
+        either way Bambuddy has stopped drying that unit.
+
         Worth sending immediately (as every notification is; a daily digest only
         repeats it): it reports that
         Bambuddy has STOPPED doing something, and a report of inaction that
@@ -2448,7 +2455,8 @@ class NotificationService:
             "cycles": str(cycles),
         }
 
-        title, message = await self._build_message_from_template(db, "ams_drying_suspended", variables)
+        template = "ams_drying_not_heating" if never_heated else "ams_drying_suspended"
+        title, message = await self._build_message_from_template(db, template, variables)
         await self._send_to_providers(
             providers,
             title,

+ 99 - 6
backend/app/services/opentag3d.py

@@ -1,11 +1,15 @@
 """OpenTag3D NDEF encoder for NTAG tags.
 
 Encodes spool data as an OpenTag3D NDEF message ready to write to NTAG
-starting at page 4 (after the manufacturer pages).
+starting at page 4, the first page of the data area.
+
+The Capability Container is not part of it: on NTAG21x it is page 3, set at
+the factory. A copy written from page 4 lands in the data area, where a Type 2
+reader takes it for an unknown TLV, skips the NDEF message and reports an
+empty tag (#3500).
 
 NDEF structure:
-  [CC: E1 10 12 00]              - Capability Container (4 bytes, page 4)
-  [TLV: 03 len]                  - NDEF Message TLV (2 bytes)
+  [TLV: 03 len]                  - NDEF Message TLV (2 bytes, page 4)
   [NDEF record header]           - D2 15 payload_len (3 bytes: MB|ME|SR, TNF=MIME, type_len=21)
   [Type: "application/opentag3d"] - 21 bytes
   [Payload: OpenTag3D fields]    - 102 bytes
@@ -99,8 +103,98 @@ def _build_payload(spool: Spool) -> bytes:
     )
 
 
+# OpenTag3D 2.004 (https://opentag3d.info/spec). 2.000 moved every field and
+# dropped NTAG213: this layout does not fit one, so v1 stays for those tags and
+# the SpoolBuddy daemon picks by the tag's size (#3500).
+V2_TAG_VERSION = 2004
+# Up to the end of the last field written (empty spool weight, 0xA0-0xA1). The
+# spec reads missing bytes as 0 (2.001), so the rest need not be written.
+V2_PAYLOAD_SIZE = 0xA2
+
+
+def _rgba_bytes(value: str | None) -> bytes:
+    """4 RGBA bytes from 6- or 8-char hex; 6 chars means opaque."""
+    text = (value or "").strip().lstrip("#")
+    if len(text) == 6:
+        text += "FF"
+    try:
+        return bytes.fromhex(text[:8].ljust(8, "0"))
+    except ValueError:
+        logger.warning("OpenTag3D encoder: invalid colour %r, encoding as transparent black", value)
+        return b"\x00\x00\x00\x00"
+
+
+def _put_text(buf: bytearray, offset: int, size: int, value: str | None) -> None:
+    """UTF-8, cut to *size* bytes without splitting a character, space-padded."""
+    raw = (value or "").encode("utf-8")[:size].decode("utf-8", "ignore").encode("utf-8")
+    buf[offset : offset + size] = raw.ljust(size, b" ")
+
+
+def _temp(celsius: int | None) -> int:
+    """A temperature as the spec stores it: degrees C divided by 5, one byte."""
+    try:
+        return max(0, min(int(celsius or 0) // 5, 255))
+    except (TypeError, ValueError):
+        return 0
+
+
+def _build_v2_payload_from_dict(data: dict) -> bytes:
+    """OpenTag3D 2.004 payload from the same field dict as the v1 one.
+
+    Written: material, modifiers, manufacturer, colour name, colours 1-4
+    (2-4 from ``extra_colors``), diameter, print temperatures (target = the
+    minimum, as v1 wrote it), target weight and empty spool weight. Bambuddy
+    tracks no density or bed temperature, so those stay 0 (not set), and
+    chamber temperature 0 means no heating.
+    """
+    buf = bytearray(V2_PAYLOAD_SIZE)
+    struct.pack_into(">H", buf, 0x00, V2_TAG_VERSION)
+    _put_text(buf, 0x02, 5, data.get("material"))
+    _put_text(buf, 0x07, 5, data.get("subtype"))
+    _put_text(buf, 0x0C, 16, data.get("brand"))
+    _put_text(buf, 0x1C, 32, data.get("color_name"))
+    buf[0x3C:0x40] = _rgba_bytes(data.get("rgba") or "00000000")
+    extra = [tok for tok in (data.get("extra_colors") or "").split(",") if tok.strip()]
+    for i, colour in enumerate(extra[:3]):
+        offset = 0x40 + 4 * i
+        buf[offset : offset + 4] = _rgba_bytes(colour)
+    # 0x8C: diameter in micrometres
+    struct.pack_into(">H", buf, 0x8C, 1750)
+    buf[0x90] = _temp(data.get("nozzle_temp_min"))  # target print
+    buf[0x91] = _temp(data.get("nozzle_temp_min"))  # minimum print
+    buf[0x92] = _temp(data.get("nozzle_temp_max"))  # maximum print
+    struct.pack_into(">H", buf, 0x9E, max(0, min(int(data.get("label_weight") or 0), 65535)))
+    struct.pack_into(">H", buf, 0xA0, max(0, min(int(data.get("core_weight") or 0), 65535)))
+    return bytes(buf)
+
+
+def _spool_fields(spool: Spool) -> dict:
+    return {
+        "material": spool.material,
+        "subtype": spool.subtype,
+        "brand": spool.brand,
+        "color_name": spool.color_name,
+        "rgba": spool.rgba,
+        "extra_colors": spool.extra_colors,
+        "label_weight": spool.label_weight,
+        "core_weight": spool.core_weight,
+        "nozzle_temp_min": spool.nozzle_temp_min,
+        "nozzle_temp_max": spool.nozzle_temp_max,
+    }
+
+
+def encode_opentag3d_v2(spool: Spool) -> bytes:
+    """Encode a spool as an OpenTag3D 2.004 NDEF message, for NTAG215/216."""
+    return _encode_ndef(_build_v2_payload_from_dict(_spool_fields(spool)))
+
+
+def encode_opentag3d_v2_from_mapped(mapped: MappedSpoolFields) -> bytes:
+    """:func:`encode_opentag3d_v2` for a Spoolman-mapped spool dict."""
+    return _encode_ndef(_build_v2_payload_from_dict(dict(mapped)))
+
+
 def _encode_ndef(payload: bytes) -> bytes:
-    """Wrap a 102-byte payload in CC + TLV + NDEF record + terminator."""
+    """Wrap a payload in TLV + NDEF record + terminator."""
     mime_type = OPENTAG3D_MIME_TYPE
 
     # NDEF record: MB|ME|SR (0xD0) | TNF=MIME (0x02) => 0xD2
@@ -114,9 +208,8 @@ def _encode_ndef(payload: bytes) -> bytes:
     else:
         tlv = bytes([0x03, 0xFF, (ndef_len >> 8) & 0xFF, ndef_len & 0xFF])
 
-    cc = bytes([0xE1, 0x10, 0x12, 0x00])
     terminator = bytes([0xFE])
-    return cc + tlv + ndef_record + terminator
+    return tlv + ndef_record + terminator
 
 
 def encode_opentag3d(spool: Spool) -> bytes:

+ 9 - 9
backend/app/services/print_cost_estimate.py

@@ -17,8 +17,10 @@ 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.services.printer_manager import printer_tray_layout
 from backend.app.utils import threemf_tools
 from backend.app.utils.safe_path import safe_join_under
+from backend.app.utils.tray_ids import TrayLayout
 
 logger = logging.getLogger(__name__)
 
@@ -161,12 +163,8 @@ def _parse_mapping(mapping: list[int] | str | None) -> list[int] | None:
     return None
 
 
-def _global_tray_id(assignment) -> int:
-    if assignment.ams_id == 255:
-        return 254 + assignment.tray_id
-    if assignment.ams_id >= 128:
-        return assignment.ams_id
-    return assignment.ams_id * 4 + assignment.tray_id
+def _global_tray_id(assignment, layout: TrayLayout) -> int:
+    return layout.tray_from_slot_key(assignment.ams_id, assignment.tray_id)
 
 
 async def _spoolman_cost_by_tray(db: AsyncSession, printer_id: int, trays: set[int]) -> dict[int, float | None]:
@@ -178,6 +176,7 @@ async def _spoolman_cost_by_tray(db: AsyncSession, printer_id: int, trays: set[i
     from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
     from backend.app.services.spoolman_tracking import _get_spoolman_client_with_fallback, _spool_cost_per_gram
 
+    layout = printer_tray_layout(printer_id)
     rows = [
         row
         for row in (
@@ -185,7 +184,7 @@ async def _spoolman_cost_by_tray(db: AsyncSession, printer_id: int, trays: set[i
         )
         .scalars()
         .all()
-        if _global_tray_id(row) in trays
+        if _global_tray_id(row, layout) in trays
     ]
     if not rows:
         return {}
@@ -196,7 +195,7 @@ async def _spoolman_cost_by_tray(db: AsyncSession, printer_id: int, trays: set[i
             return {}
         for row in rows:
             per_gram = _spool_cost_per_gram(await client.get_spool(row.spoolman_spool_id))
-            by_tray[_global_tray_id(row)] = per_gram * 1000.0 if per_gram is not None else None
+            by_tray[_global_tray_id(row, layout)] = per_gram * 1000.0 if per_gram is not None else None
     except Exception as exc:  # noqa: BLE001 — an estimate falls back to the default rate
         logger.debug("Spoolman prices unavailable for the queue estimate: %s", exc)
     return by_tray
@@ -315,7 +314,8 @@ async def estimate_queue_source_cost(
             .scalars()
             .all()
         )
-        cost_by_tray = {_global_tray_id(a): a.spool.cost_per_kg for a in assignments}
+        layout = printer_tray_layout(printer_id)
+        cost_by_tray = {_global_tray_id(a, layout): a.spool.cost_per_kg for a in assignments}
 
     total = 0.0
     for filament in usage:

+ 134 - 32
backend/app/services/print_scheduler.py

@@ -58,6 +58,7 @@ from backend.app.services.notification_service import notification_service
 from backend.app.services.print_cost_estimate import estimate_queue_source_cost
 from backend.app.services.printer_manager import (
     printer_manager,
+    printer_tray_layout,
     supports_airduct,
     supports_chamber_heater,
     supports_chamber_temp,
@@ -92,11 +93,17 @@ from backend.app.utils.threemf_tools import (
     plate_bed_temperature,
     select_plate_gcode_name,
 )
+from backend.app.utils.tray_ids import (
+    TrayLayout,
+    is_ams_ht_id,
+    is_external_tray,
+    unit_label,
+)
 
 logger = logging.getLogger(__name__)
 
 
-def _ams_slot_label(ams_id: int, tray_id: int) -> str:
+def _ams_slot_label(ams_id: int, tray_id: int, layout: TrayLayout | None = None) -> str:
     """Human slot name for an assignment tuple, in the spelling already in use.
 
     Composed from the notification side's own pair rather than spelled out again
@@ -110,8 +117,11 @@ def _ams_slot_label(ams_id: int, tray_id: int) -> str:
     Naming the same slot two ways is its own defect once an alert and the
     Inventory page are meant to be talking about the same tray, so this defers
     rather than adding a fifth spelling.
+
+    ``layout`` is the printer's (``printer_tray_layout``): an H3C names its
+    per-hotend external inputs "Ext-n".
     """
-    return _slot_label_from_global_tray(_global_tray_from_assignment(ams_id, tray_id))
+    return _slot_label_from_global_tray(_global_tray_from_assignment(ams_id, tray_id, layout), layout)
 
 
 # Minimum seconds between low-filament checks. Matches the scheduler's idle
@@ -272,6 +282,20 @@ class _KeepWarmEntry:
 # manual or firmware-run dry is untouched.
 AUTO_DRY_REARM_COOLDOWN_SECONDS = 30 * 60
 AUTO_DRY_MAX_UNPRODUCTIVE_CYCLES = 2
+# A cycle whose timer stood still (#3478): the firmware took the command and
+# left the heater off, so dry_time stays put with dry_status Off. It tells
+# nothing about humidity, so it has its own counter; this many in a row
+# suspends the unit with a message that says the unit did not heat, not that
+# the threshold is too low.
+AUTO_DRY_MAX_NEVER_HEATED_CYCLES = 2
+
+
+def _never_heated(unit_state: dict[str, object]) -> int:
+    """Cycles in a row whose timer stood still on this unit (#3478)."""
+    count = unit_state.get("never_heated")
+    return count if isinstance(count, int) else 0
+
+
 # Sustained-humidity wait (#2518): an above-threshold streak is only
 # "continuous" if some pass observed it within the gap ceiling. A unit that
 # goes unobserved longer (print running, printer disconnected, sensor silent)
@@ -769,15 +793,16 @@ def _consumed_mapping_entries(mapping: list | None, required: list[dict] | None)
     return entries or None
 
 
-def _is_external_tray(tray_id) -> bool:
-    """True for an explicit external-spool selection (254/255), not for an
-    unresolved slot and not for an AMS tray."""
+def _is_external_tray(tray_id, rack: bool = False) -> bool:
+    """True for an explicit external-spool selection (254/255, and 247-253 on a
+    ``rack`` printer), not for an unresolved slot and not for an AMS tray."""
     if tray_id is None:
         return False
     try:
-        return int(tray_id) >= _EXTERNAL_TRAY_ID_MIN
+        value = int(tray_id)
     except (TypeError, ValueError):
         return False
+    return value >= _EXTERNAL_TRAY_ID_MIN or is_external_tray(value, rack)
 
 
 def _might_be_dual_nozzle(printer_model: str | None, status) -> bool:
@@ -806,7 +831,8 @@ def _might_be_dual_nozzle(printer_model: str | None, status) -> bool:
     if raw.get("ams_extruder_map"):
         return True
     vt_trays = raw.get("vt_tray") or []
-    return isinstance(vt_trays, list) and len(vt_trays) > 1
+    # Not a nozzle-rack printer's one external input per hotend (H3C, 247-255).
+    return isinstance(vt_trays, list) and len(vt_trays) > 1 and not TrayLayout.from_units(None, vt_trays).rack
 
 
 def _int_or(value, default: int) -> int:
@@ -857,15 +883,14 @@ async def _edited_since_loaded(db: AsyncSession, item: PrintQueueItem) -> bool:
     return True
 
 
-def _global_tray_id(ams_id: int, tray_id: int) -> int:
-    """Bambu's flat tray addressing: ``ams_id * 4 + tray_id`` for a four-slot
-    unit, and the bare unit id for an AMS-HT (ids from 128, one tray each).
+def _global_tray_id(ams_id: int, tray_id: int, layout: TrayLayout | None = None) -> int:
+    """Bambu Studio's global tray id (``utils.tray_ids``) of a slot on the printer ``layout`` describes.
 
     Mirrors the calculation in ``_build_loaded_filaments``, which is what
     produces the ids stored in ``PrintQueueItem.ams_mapping`` — the two must
     agree or a mapping cannot be read back against live tray telemetry.
     """
-    return ams_id if ams_id >= 128 else ams_id * 4 + tray_id
+    return (layout or TrayLayout()).global_tray_id(ams_id, tray_id)
 
 
 def _used_global_tray_ids(item: PrintQueueItem | None) -> set[int] | None:
@@ -1229,6 +1254,17 @@ def _effective_plate_id(explicit_plate_id: int | None, file_path: Path) -> int:
     return resolved if resolved is not None else 1
 
 
+def _wear_order(printer: Printer) -> tuple[float, int]:
+    """Sort key for printers a model job could run on: fewest print hours, then id (#3480).
+
+    The same hours as maintenance's ``get_printer_total_hours``: the runtime
+    counter plus the baseline offset a user set for a second-hand or reset
+    machine.
+    """
+    hours = (printer.runtime_seconds or 0) / 3600.0 + (printer.print_hours_offset or 0.0)
+    return hours, printer.id
+
+
 class PrintScheduler:
     """Background scheduler that processes the print queue."""
 
@@ -3247,6 +3283,13 @@ class PrintScheduler:
         answer "which printers can this job run on" from one query — a job can
         only be woken onto a printer the matcher would also have considered.
         ``printer_scope`` is the job creator's (#1727).
+
+        Fewest print hours first (#3480): both take the first printer that
+        fits, so an "Any" job goes to the least worn machine and a farm's jobs
+        spread by wear instead of all landing on the lowest id. The hours are
+        the maintenance counter's (runtime plus the baseline offset), which
+        counts every print, wherever it was started. Ties (printers still at
+        zero) go by id, so the order is stable.
         """
         normalized_model = normalize_printer_model(model) or model
         query = (
@@ -3259,7 +3302,7 @@ class PrintScheduler:
         if (clause := printer_scope.where_strict(Printer.id)) is not None:
             query = query.where(clause)
         result = await db.execute(query)
-        return list(result.scalars().all())
+        return sorted(result.scalars().all(), key=_wear_order)
 
     async def _wakeable_printer_ids(self, db: AsyncSession) -> set[int]:
         """Printer IDs that at least one enabled ``auto_on`` plug can power on.
@@ -3333,7 +3376,8 @@ class PrintScheduler:
                 continue
             required_types, filament_overrides = _filament_constraints(candidate)
             printers = await self._printers_for_model(db, candidate.target_model, target_location, printer_scope)
-            for printer in sorted(printers, key=lambda p: p.id):
+            # Fewest print hours first, as the matcher picks (#3480).
+            for printer in printers:
                 if printer.id in exclude_ids or printer.id not in wakeable_ids:
                     continue
                 if printer_manager.is_connected(printer.id):
@@ -3595,8 +3639,9 @@ class PrintScheduler:
                     logger.debug("Skipping printer %s (%s) - no matching override colors", printer.id, printer.name)
                     continue
             else:
-                # No preference ordering needed: the first eligible printer
-                # wins, unless its spools are too light for the job (#3137).
+                # The first eligible printer wins, which is the one with the
+                # fewest print hours (#3480, see _printers_for_model), unless
+                # its spools are too light for the job (#3137).
                 if is_short is not None and await is_short(printer.id):
                     if short_fallback is None:
                         short_fallback = printer.id
@@ -3608,7 +3653,8 @@ class PrintScheduler:
                     continue
                 return printer.id, None
 
-        # If we have candidates from preference override matching, pick the one with most color matches
+        # If we have candidates from preference override matching, pick the one with most color matches.
+        # The sort is stable: among equal matches the fewest print hours still go first (#3480).
         if candidates:
             candidates.sort(key=lambda c: c[1], reverse=True)
             if is_short is not None:
@@ -4266,6 +4312,7 @@ class PrintScheduler:
             # place.
             return None
         by_tray = {f["global_tray_id"]: f for f in loaded}
+        layout = TrayLayout.from_state(status)
 
         # Cheap pass first, on live status alone: every tray the mapping names
         # has to exist here. This catches a foreign mapping without opening the
@@ -4276,7 +4323,7 @@ class PrintScheduler:
                 continue
             if tray in by_tray:
                 continue
-            if tray >= 254:
+            if tray >= 254 or layout.is_external(tray):
                 # An external feed we have not heard about is absence of
                 # evidence about *that slot* — the rest of the mapping is still
                 # worth judging, so skip it rather than abandoning the pass.
@@ -4325,7 +4372,7 @@ class PrintScheduler:
                 # has to match too: with only another colour in the AMS, the
                 # printer asking for the spool beats printing in that colour.
                 want = canonical_filament_type(req.get("type"))
-                if tray >= 254 and tray in empty_external and want:
+                if (tray >= 254 or layout.is_external(tray)) and tray in empty_external and want:
                     ams_tray = next(
                         (
                             f
@@ -4706,10 +4753,11 @@ class PrintScheduler:
 
         # Parse AMS units from raw_data
         ams_data = status.raw_data.get("ams", [])
+        layout = TrayLayout.from_state(status)
         for ams_unit in ams_data:
             ams_id = int(ams_unit.get("id", 0))
             trays = ams_unit.get("tray", [])
-            is_ht = 128 <= ams_id <= 135  # AMS-HT unit ids; a regular AMS may list fewer trays
+            is_ht = is_ams_ht_id(ams_id)  # AMS-HT unit ids; a regular AMS may list fewer trays
 
             for tray in trays:
                 tray_type = tray.get("tray_type")
@@ -4722,7 +4770,7 @@ class PrintScheduler:
                     color = self._normalize_color(tray_color)
                     # Calculate global tray ID
                     # AMS-HT units have IDs starting at 128 with a single tray
-                    global_tray_id = ams_id if ams_id >= 128 else ams_id * 4 + tray_id
+                    global_tray_id = layout.global_tray_id(ams_id, tray_id)
 
                     filaments.append(
                         {
@@ -5745,7 +5793,7 @@ class PrintScheduler:
                     continue
                 printers[printer_id] = printer
             printer = printers[printer_id]
-            slot = _ams_slot_label(ams_id, tray_id)
+            slot = _ams_slot_label(ams_id, tray_id, printer_tray_layout(printer_id, printer_manager))
             if send_notification:
                 try:
                     await notification_service.on_filament_low(
@@ -6312,7 +6360,15 @@ class PrintScheduler:
                 # The firmware stops when the configured duration elapses; scheduling
                 # stops (print takes priority, queue no longer needs drying) are
                 # handled separately via _stop_drying().
-                if dry_time > 0:
+                #
+                # A parked countdown is not a running cycle (#3478): the firmware
+                # took the command and left the heater off, so dry_time sits at
+                # its full duration and never reaches 0 on its own. Treating it
+                # as running blocked auto-drying on that unit for days. A sample
+                # the stall detector has not classified yet (its first 150 s)
+                # still counts as running.
+                parked = is_countdown_parked(ams_data)
+                if dry_time > 0 and not parked:
                     if pid not in self._drying_in_progress:
                         # Drying we didn't start (manual or from before restart) —
                         # track it so scheduling stops still apply; never auto-stop it.
@@ -6349,7 +6405,25 @@ class PrintScheduler:
                     # below, so the wait overlaps the cooldown, never stacks on
                     # top of it).
                     self._auto_dry_above.pop(unit_key, None)
-                    if humidity is not None and humidity > humidity_threshold:
+                    if parked:
+                        # The timer stood still (never started, or paused by the
+                        # power limit or the print), so the cycle says nothing
+                        # about humidity: count it apart from the unproductive
+                        # cycles below (#3478).
+                        never_heated = _never_heated(unit_state) + 1
+                        unit_state["never_heated"] = never_heated
+                        logger.warning(
+                            "Auto-drying: printer %d AMS %d — drying cycle not running: the timer stands "
+                            "still at %dm with the status Off, %d in a row",
+                            pid,
+                            ams_id,
+                            dry_time,
+                            never_heated,
+                        )
+                    else:
+                        # A cycle that counted down ran; the parked streak is over.
+                        unit_state.pop("never_heated", None)
+                    if not parked and humidity is not None and humidity > humidity_threshold:
                         best = unit_state.get("best_end_humidity")
                         if isinstance(best, int) and humidity < best:
                             # Still coming down. Keep going, however far the
@@ -6399,6 +6473,7 @@ class PrintScheduler:
                         unit_state.pop("suspended", None)
                         unit_state.pop("unproductive", None)
                         unit_state.pop("best_end_humidity", None)
+                        unit_state.pop("never_heated", None)
                     # A real below-threshold reading ends any sustained-wait
                     # streak (#2518) -- "continuously above" means exactly that.
                     # An absent reading (humidity is None) is no-information and
@@ -6465,15 +6540,33 @@ class PrintScheduler:
                 if unit_state is not None:
                     if unit_state.get("suspended"):
                         logger.debug(
-                            "Auto-drying: printer %d AMS %d skipped — suspended, %d cycles left humidity "
-                            "above the %d%% threshold",
+                            "Auto-drying: printer %d AMS %d skipped — suspended until humidity is back at "
+                            "or below the %d%% threshold",
                             pid,
                             ams_id,
-                            int(unit_state.get("unproductive", 0)),
                             humidity_threshold,
                         )
                         continue
 
+                    never_heated = _never_heated(unit_state)
+                    if never_heated >= AUTO_DRY_MAX_NEVER_HEATED_CYCLES:
+                        unit_state["suspended"] = True
+                        logger.warning(
+                            "Auto-drying: printer %d AMS %d — suspending automatic drying. The printer "
+                            "accepted %d drying commands in a row, but each time the timer stood still and "
+                            "the unit did not heat. Clear the timer with Stop on the printer card and check "
+                            "the AMS power; automatic drying resumes once humidity is back at or below the "
+                            "%d%% threshold.",
+                            pid,
+                            ams_id,
+                            never_heated,
+                            humidity_threshold,
+                        )
+                        await self._notify_auto_drying_suspended(
+                            db, printer, ams_id, humidity, humidity_threshold, never_heated, never_heated=True
+                        )
+                        continue
+
                     if int(unit_state.get("unproductive", 0)) >= AUTO_DRY_MAX_UNPRODUCTIVE_CYCLES:
                         unit_state["suspended"] = True
                         logger.warning(
@@ -6590,9 +6683,14 @@ class PrintScheduler:
         humidity: int,
         threshold: int,
         cycles: int,
+        *,
+        never_heated: bool = False,
     ) -> None:
         """Tell the user auto-drying has given up on one AMS unit (#2770).
 
+        ``never_heated`` picks the message for cycles the printer accepted but
+        never ran (#3478) instead of cycles that left humidity high.
+
         Fires once per suspension — the caller sets ``suspended`` before calling
         and every later pass short-circuits on it — because the whole point is
         that Bambuddy has stopped acting. Somebody whose printer sits in another
@@ -6602,7 +6700,7 @@ class PrintScheduler:
         Never raises: a notification provider being down must not stop the
         suspension itself from taking effect.
         """
-        ams_label = f"HT-{chr(65 + (ams_id - 128))}" if ams_id >= 128 else f"AMS-{chr(65 + ams_id)}"
+        ams_label = unit_label(ams_id, printer_tray_layout(printer.id, printer_manager).ams_type(ams_id))
         try:
             await notification_service.on_ams_drying_suspended(
                 printer.id,
@@ -6612,6 +6710,7 @@ class PrintScheduler:
                 float(threshold),
                 cycles,
                 db,
+                never_heated=never_heated,
             )
         except Exception as e:
             logger.warning("Failed to send auto-drying suspended notification: %s", e)
@@ -6664,7 +6763,9 @@ class PrintScheduler:
                 continue
             # Check if any AMS unit is still drying
             ams_list = state.raw_data.get("ams", [])
-            any_drying = any(int(a.get("dry_time") or 0) > 0 for a in ams_list)
+            # A parked timer is not drying (#3478): it never ends on its own,
+            # so it would keep the printer in here for good.
+            any_drying = any(int(a.get("dry_time") or 0) > 0 and not is_countdown_parked(a) for a in ams_list)
             if not any_drying:
                 to_remove.append(pid)
         for pid in to_remove:
@@ -7135,6 +7236,7 @@ class PrintScheduler:
         if not isinstance(ams_list, list):
             ams_list = []
         best = 0
+        layout = TrayLayout.from_state(state)
         for ams in ams_list:
             if not isinstance(ams, dict):
                 continue
@@ -7147,10 +7249,10 @@ class PrintScheduler:
                 # contract, so step over it instead.
                 if not isinstance(tray, dict):
                     continue
-                if used is not None and _global_tray_id(ams_id, _int_or(tray.get("id"), 0)) not in used:
+                if used is not None and _global_tray_id(ams_id, _int_or(tray.get("id"), 0), layout) not in used:
                     continue
                 best = max(best, self._target_for_tray_type(tray.get("tray_type"), targets))
-        if used is not None and any(t >= _EXTERNAL_TRAY_ID_MIN for t in used):
+        if used is not None and any(t >= _EXTERNAL_TRAY_ID_MIN or layout.is_external(t) for t in used):
             for vt in raw_data.get("vt_tray") or []:
                 if not isinstance(vt, dict):
                     continue
@@ -9902,7 +10004,7 @@ class PrintScheduler:
             # from a hand-edited row) failing where it always failed, in the
             # command builder, rather than here.
             and isinstance(ams_mapping, list)
-            and any(_is_external_tray(t) for t in ams_mapping)
+            and any(_is_external_tray(t, TrayLayout.from_state(pre_status).rack) for t in ams_mapping)
             and not _might_be_dual_nozzle(printer.model, pre_status)
         ):
             from backend.app.services.filament_requirements import extract_filament_requirements
@@ -9910,7 +10012,7 @@ class PrintScheduler:
             consumed = _consumed_mapping_entries(
                 ams_mapping, extract_filament_requirements(file_path, plate_id=effective_plate_id)
             )
-            if consumed and all(_is_external_tray(t) for t in consumed):
+            if consumed and all(_is_external_tray(t, TrayLayout.from_state(pre_status).rack) for t in consumed):
                 # Exact for this plate: the command builder must not turn the
                 # AMS back on for a real tray another plate of the file uses,
                 # whether the job asked for the AMS or already had it off.

+ 84 - 25
backend/app/services/printer_manager.py

@@ -20,6 +20,16 @@ from backend.app.services.bambu_mqtt import (
 from backend.app.services.pause_eta import next_pause
 from backend.app.utils.ams_humidity import ams_humidity_percent
 from backend.app.utils.kprofile_lookup import build_slot_k_resolver
+from backend.app.utils.printer_models import has_ams3h
+from backend.app.utils.tray_ids import (
+    RACK_SLOT_COUNT,
+    TrayLayout,
+    external_slot_index,
+    is_ams_ht_id,
+    is_external_tray,
+    mapped_trays_with_slot,
+    unit_ams_type,
+)
 
 logger = logging.getLogger(__name__)
 
@@ -39,6 +49,7 @@ CHAMBER_TEMP_SUPPORTED_MODELS = frozenset(
         "H2D",
         "H2DPRO",
         "H2S",  # H2 series
+        "H3C",  # H3 series
         # Internal codes (from MQTT/SSDP)
         "BL-P001",  # X1/X1C
         "C13",  # X1E
@@ -50,6 +61,7 @@ CHAMBER_TEMP_SUPPORTED_MODELS = frozenset(
         "O1E",  # H2D Pro
         "O2D",  # H2D Pro (alternate code)
         "N7",  # P2S
+        "N10",  # H3C
     ]
 )
 
@@ -114,6 +126,7 @@ CHAMBER_HEATER_MODELS = frozenset(
         "H2D",
         "H2DPRO",
         "H2S",
+        "H3C",
         "X1E",
         "X2D",
         # Internal codes (from MQTT/SSDP)
@@ -125,6 +138,7 @@ CHAMBER_HEATER_MODELS = frozenset(
         "O2D",  # H2D Pro alternate code
         "O1S",  # H2S
         "N6",  # X2D
+        "N10",  # H3C (65 °C active heater; BambuStudio support_chamber_temp_control "1")
     ]
 )
 
@@ -134,7 +148,7 @@ def supports_chamber_heater(model: str | None) -> bool:
 
     The chamber temperature SENSOR is more widely deployed than the chamber
     HEATER — X1C/P2S report chamber temp but ignore M141. Only H2C, H2D,
-    H2D Pro, H2S, X1E, X2D actually heat. Sensor-only models silently swallow the
+    H2D Pro, H2S, H3C, X1E, X2D actually heat. Sensor-only models silently swallow the
     command at the firmware level, so we 400 at the route to surface that.
     """
     if not model:
@@ -143,7 +157,7 @@ def supports_chamber_heater(model: str | None) -> bool:
 
 
 # Models with a cooling / heating airduct flap. Same set as the frontend
-# PrintersPage airduct-toggle whitelist (P2S, X2D, H2D, H2D Pro, H2C, H2S).
+# PrintersPage airduct-toggle whitelist (P2S, X2D, H2D, H2D Pro, H2C, H2S, H3C).
 # X1E has a chamber heater but NO airduct flap — the warm-air recirculation
 # happens via the fixed front-door inlet, so no `set_airduct` command is
 # needed (and the firmware ignores it). P2S has an airduct but no heater —
@@ -160,6 +174,7 @@ CHAMBER_AIRDUCT_MODELS = frozenset(
         "H2D",
         "H2DPRO",
         "H2S",
+        "H3C",
         # Internal codes (from MQTT/SSDP)
         "N7",  # P2S
         "N6",  # X2D
@@ -169,6 +184,7 @@ CHAMBER_AIRDUCT_MODELS = frozenset(
         "O1E",  # H2D Pro
         "O2D",  # H2D Pro alternate code
         "O1S",  # H2S
+        "N10",  # H3C
     ]
 )
 
@@ -176,7 +192,7 @@ CHAMBER_AIRDUCT_MODELS = frozenset(
 def supports_airduct(model: str | None) -> bool:
     """Check if a printer model has a cooling / heating airduct mode toggle.
 
-    Mirrors the frontend PrintersPage `['P2S', 'X2D', 'H2D', 'H2C', 'H2S']`
+    Mirrors the frontend PrintersPage `['P2S', 'X2D', 'H2D', 'H2C', 'H2S', 'H3C']`
     + H2D Pro whitelist. Distinct from `supports_chamber_heater` — P2S has
     the airduct toggle but no active heater, and X1E has the heater but no
     airduct. The preheat stage cares about the intersection (heater AND
@@ -543,6 +559,12 @@ class PrinterManager:
         except Exception as e:
             logger.warning("Failed to publish plate-clear state for printer %d: %s", printer_id, e)
 
+        state = self.get_status(printer_id)
+        if state:
+            from backend.app.services.wled import wled_manager
+
+            wled_manager.handle_status(printer_id, state, awaiting_plate_clear=awaiting)
+
         # Only the rising edge is worth a notification — "the bed is now free"
         # is not an action item, and the queue clears the gate by itself.
         if not awaiting:
@@ -1290,6 +1312,7 @@ def _trays_on_extruder(
     ams_layout: list[tuple[int, bool]],
     active_extruder: int | None,
     ams_extruder_map: dict | None,
+    tray_layout: TrayLayout,
 ) -> set[int] | None:
     """The global trays a local ``raw_slot`` can be on the AMS units wired to
     ``active_extruder`` (an AMS-HT's one tray for slot 0), or None when the
@@ -1307,10 +1330,8 @@ def _trays_on_extruder(
             return None
         if nozzle != active_extruder:
             continue
-        if not is_ht:
-            trays.add(ams_id * 4 + raw_slot)
-        elif raw_slot == 0:
-            trays.add(ams_id)
+        if not is_ht or raw_slot == 0:
+            trays.add(tray_layout.global_tray_id(ams_id, raw_slot))
     return trays
 
 
@@ -1320,6 +1341,7 @@ def resolve_expected_tray(
     mapping_raw: object,
     active_extruder: int | None = None,
     ams_extruder_map: dict | None = None,
+    tray_layout: TrayLayout | None = None,
 ) -> int | None:
     """Globalise a raw firmware ``tray_tar``/``tray_pre`` value for the runout UI (#2587).
 
@@ -1343,35 +1365,34 @@ def resolve_expected_tray(
         * no regular AMS -> ``None``
     - ``4``-``15`` -> already a global regular-AMS ID, returned as-is
 
+    A printer with a rack AMS (``tray_layout``) reports the rack's raw slot 0-8.
+
     Returns ``None`` for anything it can't place, so the caller surfaces a
     "check the printer" message instead of pointing at the wrong slot.
     """
     if raw_slot is None or raw_slot in (255, -1):
         return None
+    layout = tray_layout or TrayLayout()
+    if layout.rack_units:
+        rack_tray = layout.raw_slot_to_global(raw_slot)
+        if rack_tray is not None:
+            return rack_tray
+        return raw_slot if is_ams_ht_id(raw_slot) or layout.is_external(raw_slot) else None
     if raw_slot == 254:
         return 254
-    if 128 <= raw_slot <= 135:
+    if is_ams_ht_id(raw_slot):
         return raw_slot
     if 0 <= raw_slot <= 3:
         regular = [ams_id for ams_id, is_ht in ams_layout if not is_ht]
         if len(regular) == 1:
-            return regular[0] * 4 + raw_slot
+            return layout.global_tray_id(regular[0], raw_slot)
         if len(regular) > 1:
-            candidates: set[int] = set()
-            for value in mapping_raw if isinstance(mapping_raw, list) else []:
-                if not isinstance(value, int) or value >= 65535:
-                    continue
-                ams_hw_id = value >> 8
-                slot = value & 0xFF
-                if 0 <= ams_hw_id <= 3 and (slot & 0x03) == raw_slot:
-                    candidates.add(ams_hw_id * 4 + raw_slot)
-                elif 128 <= ams_hw_id <= 135 and raw_slot == 0:
-                    candidates.add(ams_hw_id)
+            candidates = set(mapped_trays_with_slot(mapping_raw, raw_slot))
             if len(candidates) == 1:
                 return candidates.pop()
             # The print paused on the active nozzle, so the slot is on an AMS
             # that feeds it.
-            narrowed = _trays_on_extruder(raw_slot, ams_layout, active_extruder, ams_extruder_map)
+            narrowed = _trays_on_extruder(raw_slot, ams_layout, active_extruder, ams_extruder_map, layout)
             if narrowed is None:
                 return None
             if candidates:
@@ -1399,6 +1420,27 @@ def _ams_temp(raw: object) -> float | None:
     return value if math.isfinite(value) else None
 
 
+def _dryer_fields(ams_data: dict) -> dict:
+    """An AMS 3H's dryer (its drying zone's slots and readings, lifted onto the unit by the MQTT layer)."""
+    raw_slots = ams_data.get("dry_zone_slots")
+    dry_slots: list[int] = []
+    for raw in raw_slots if isinstance(raw_slots, list) else []:
+        try:
+            slot = int(raw)
+        except (TypeError, ValueError):
+            continue
+        if 0 <= slot < RACK_SLOT_COUNT and slot not in dry_slots:
+            dry_slots.append(slot)
+    if not dry_slots:
+        return {"dryer_slots": [], "dryer_humidity": None, "dryer_temp": None}
+    humidity = ams_humidity_percent({"humidity_raw": ams_data.get("dry_zone_humidity_raw")})
+    return {
+        "dryer_slots": dry_slots,
+        "dryer_humidity": int(round(humidity)) if humidity is not None and math.isfinite(humidity) else None,
+        "dryer_temp": _ams_temp(ams_data.get("dry_zone_temp")),
+    }
+
+
 def ams_units_to_dicts(
     ams_list: list,
     resolve_slot_k: Callable,
@@ -1477,7 +1519,7 @@ def ams_units_to_dicts(
         humidity_value = int(round(humidity_pct)) if humidity_pct is not None else None
 
         # AMS-HT by its unit id: a regular AMS can report fewer than 4 trays.
-        is_ams_ht = 128 <= ams_id <= 135
+        is_ams_ht = is_ams_ht_id(ams_id)
 
         # Active-cycle filament + target temperature for the badge.
         # Bambu does not echo the cycle's chosen filament/temp on the
@@ -1533,8 +1575,10 @@ def ams_units_to_dicts(
                 # Active-cycle filament name + target temperature
                 "dry_target_temp": dry_target_temp,
                 "dry_filament": dry_filament,
-                # Module type: "ams", "n3f", "n3s" (from get_version)
+                # Module type: "ams", "n3f", "n3s", "n10a" (from get_version)
                 "module_type": str(ams_data.get("module_type") or ""),
+                "ams_type": int(unit_ams_type(ams_id, ams_data.get("info"))),
+                **_dryer_fields(ams_data),
             }
         )
     return ams_units
@@ -1564,10 +1608,12 @@ def vt_trays_to_dicts(raw: object, resolve_slot_k: Callable) -> list[dict]:
         vt_k_value = vt_data.get("k")
         vt_cali_idx = vt_data.get("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.
+            # External holder: id 254 is Ext-L, 255 is Ext-R (an H3C's 247-253
+            # its other hotends). The resolver takes the stored tray index.
             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)
+            vt_k_value = resolve_slot_k(
+                vt_cali_idx, 255, external_slot_index(vt_id) if is_external_tray(vt_id, rack=True) else vt_id
+            )
 
         tray_id = int(vt_data.get("id", 254))
         vt_tray.append(
@@ -1623,6 +1669,7 @@ def printer_state_to_dict(
     # Parse virtual tray (external spool) — now a list
     if "vt_tray" in raw_data:
         vt_tray = vt_trays_to_dicts(raw_data["vt_tray"], resolve_slot_k)
+    tray_layout = TrayLayout.from_units(ams_units, vt_tray)
 
     # Get ams_extruder_map from raw_data (populated by MQTT handler from AMS info field)
     ams_extruder_map = raw_data.get("ams_extruder_map", {})
@@ -1682,6 +1729,7 @@ def printer_state_to_dict(
                 raw_data.get("mapping"),
                 active_extruder=state.active_extruder,
                 ams_extruder_map=nozzle_wiring(state),
+                tray_layout=tray_layout,
             )
             if state.state == "PAUSE"
             else None
@@ -1693,6 +1741,7 @@ def printer_state_to_dict(
                 raw_data.get("mapping"),
                 active_extruder=state.active_extruder,
                 ams_extruder_map=nozzle_wiring(state),
+                tray_layout=tray_layout,
             )
             if state.state == "PAUSE"
             else None
@@ -1794,6 +1843,7 @@ def printer_state_to_dict(
                 "nozzle_type": n.get("type", ""),
                 "nozzle_diameter": n.get("diameter", ""),
                 "wear": n.get("wear"),
+                "print_time_s": n.get("print_time_s"),
                 "stat": n.get("stat"),
                 "max_temp": n.get("max_temp", 0),
                 "serial_number": n.get("serial_number", ""),
@@ -1802,6 +1852,7 @@ def printer_state_to_dict(
                 # The push replaces the whole rack: without it a slot lost its
                 # material label on every push (PSF-3).
                 "filament_type": n.get("filament_type", ""),
+                "addr_id": n.get("addr_id"),
             }
             for n in (state.nozzle_rack or [])
         ],
@@ -1877,3 +1928,11 @@ async def init_printer_connections(db: AsyncSession):
                 printer.name,
                 outcome,
             )
+
+
+def printer_tray_layout(printer_id: int | None, manager: PrinterManager | None = None) -> TrayLayout:
+    """How printer ``printer_id`` numbers its trays: from its live AMS data, else its model."""
+    if printer_id is None:
+        return TrayLayout()
+    manager = manager or printer_manager
+    return TrayLayout.from_state(manager.get_status(printer_id), has_ams3h(manager.get_model(printer_id)))

+ 4 - 2
backend/app/services/slot_nozzle.py

@@ -47,6 +47,7 @@ from dataclasses import dataclass
 
 from backend.app.utils.fts_routing import slot_extruder
 from backend.app.utils.printer_models import is_dual_nozzle_model
+from backend.app.utils.tray_ids import TrayLayout
 
 logger = logging.getLogger(__name__)
 
@@ -129,8 +130,9 @@ def reports_two_nozzles(state) -> bool:
     return bool(
         (len(nozzles) > 1 and getattr(nozzles[1], "nozzle_diameter", ""))
         or raw_data.get("ams_extruder_map")
-        # isinstance, because a dict here would count its keys as trays.
-        or (isinstance(vt_trays, list) and len(vt_trays) > 1)
+        # isinstance, because a dict here would count its keys as trays. An
+        # H3C's one input per hotend is not a second extruder.
+        or (isinstance(vt_trays, list) and len(vt_trays) > 1 and not TrayLayout.from_units(None, vt_trays).rack)
     )
 
 

+ 40 - 44
backend/app/services/spool_assignment_notifications.py

@@ -8,20 +8,34 @@ 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
-
-
-def _global_tray_from_assignment(ams_id: int, tray_id: int) -> int:
+from backend.app.services.printer_manager import printer_manager, printer_tray_layout
+from backend.app.utils.tray_ids import (
+    EXTERNAL_DEPUTY_ID,
+    RACK_EXTERNAL_IDS,
+    AmsType,
+    TrayLayout,
+    decode_wire_slot,
+    external_hotend,
+)
+
+
+def _global_tray_from_assignment(ams_id: int, tray_id: int, layout: TrayLayout | None = None) -> int:
     """Convert an assignment tuple to Bambuddy global tray ID."""
-    if ams_id in (254, 255):
-        return 254 + tray_id
-    if ams_id >= 128:
-        return ams_id
-    return ams_id * 4 + tray_id
+    if ams_id == EXTERNAL_DEPUTY_ID:
+        # Older records keep the left external spool as its own ams_id.
+        return EXTERNAL_DEPUTY_ID + tray_id
+    return (layout or TrayLayout()).tray_from_slot_key(ams_id, tray_id)
 
 
-def _slot_label_from_global_tray(global_tray_id: int) -> str:
+def _slot_label_from_global_tray(global_tray_id: int, layout: TrayLayout | None = None) -> str:
     """Return a human-readable slot label from a global tray ID."""
+    layout = layout or TrayLayout()
+    if (layout.rack and layout.is_external(global_tray_id)) or (
+        RACK_EXTERNAL_IDS.start <= global_tray_id < EXTERNAL_DEPUTY_ID
+    ):
+        # One external input per hotend, numbered like Bambu Studio's "Ext-n".
+        # 247-253 are only ever such inputs, so they read so without a layout.
+        return f"Ext-{external_hotend(global_tray_id) + 1}"
     if global_tray_id == 254:
         return "Ext-L"
     if global_tray_id == 255:
@@ -31,8 +45,12 @@ def _slot_label_from_global_tray(global_tray_id: int) -> str:
     # 24-27 = A2L AMS-Lite (normalised unit 6); see a2l-am-unit-16.
     if 24 <= global_tray_id <= 27:
         return f"Lite-{(global_tray_id % 4) + 1}"
-    ams_id = global_tray_id // 4
-    tray_id = global_tray_id % 4
+    ams_id, tray_id = layout.slot_key(global_tray_id)
+    kind = layout.ams_type(ams_id)
+    if kind == AmsType.ZMULTI_A:
+        return f"3H-{tray_id + 1}"
+    if kind == AmsType.ZMULTI_B:
+        return f"B-{tray_id + 1}"
     return f"{chr(65 + ams_id)}{tray_id + 1}"
 
 
@@ -43,6 +61,7 @@ def _tray_profile_and_color_for_global_id(state: PrinterState | None, global_tra
 
     ams_raw = state.raw_data.get("ams", {})
     ams_units = ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
+    layout = TrayLayout.from_state(state)
 
     vt_trays = state.raw_data.get("vt_tray", [])
     if not isinstance(vt_trays, list):
@@ -67,8 +86,7 @@ def _tray_profile_and_color_for_global_id(state: PrinterState | None, global_tra
             if not isinstance(tray, dict):
                 continue
             tray_id = int(tray.get("id", -1))
-            candidate = ams_id if ams_id >= 128 else (ams_id * 4 + tray_id)
-            if candidate == global_tray_id:
+            if layout.global_tray_id(ams_id, tray_id) == global_tray_id:
                 profile = tray.get("tray_sub_brands") or tray.get("tray_type") or "Unknown"
                 color = tray.get("tray_color") or "Unknown"
                 return (profile, color)
@@ -76,37 +94,13 @@ def _tray_profile_and_color_for_global_id(state: PrinterState | None, global_tra
     return ("Unknown", "Unknown")
 
 
-def _decode_mqtt_mapping_to_global_trays(mapping_raw: object) -> list[int]:
+def _decode_mqtt_mapping_to_global_trays(mapping_raw: object, layout: TrayLayout | None = None) -> list[int]:
     """Decode printer MQTT mapping values into Bambuddy global tray IDs."""
     if not isinstance(mapping_raw, list) or not mapping_raw:
         return []
 
-    decoded: list[int] = []
-    for value in mapping_raw:
-        try:
-            if isinstance(value, int):
-                encoded = value
-            elif isinstance(value, str):
-                encoded = int(value, 10)
-            else:
-                continue
-        except ValueError:
-            continue
-
-        if encoded >= 65535:
-            continue
-
-        ams_hw_id = (encoded >> 8) & 0xFF
-        slot = encoded & 0xFF
-
-        if 0 <= ams_hw_id <= 3:
-            decoded.append(ams_hw_id * 4 + (slot & 0x03))
-        elif 128 <= ams_hw_id <= 135:
-            decoded.append(ams_hw_id)
-        elif ams_hw_id in (254, 255):
-            decoded.append(255 if slot == 255 else 254)
-
-    return decoded
+    decoded = (decode_wire_slot(value, layout) for value in mapping_raw)
+    return [tray for tray in decoded if tray is not None]
 
 
 async def notify_missing_spool_assignments_on_print_start(
@@ -120,7 +114,8 @@ async def notify_missing_spool_assignments_on_print_start(
         [value for value in explicit_mapping if isinstance(value, int)] if isinstance(explicit_mapping, list) else []
     )
     raw_mapping = data.get("raw_data", {}).get("mapping") if isinstance(data.get("raw_data"), dict) else None
-    decoded_values = _decode_mqtt_mapping_to_global_trays(raw_mapping)
+    layout = printer_tray_layout(printer_id, printer_manager)
+    decoded_values = _decode_mqtt_mapping_to_global_trays(raw_mapping, layout)
     mapping_values = explicit_values if explicit_values else decoded_values
 
     used_global_trays = {value for value in mapping_values if value >= 0}
@@ -146,7 +141,7 @@ async def notify_missing_spool_assignments_on_print_start(
             # 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}
+            assigned_global_trays = {_global_tray_from_assignment(row.ams_id, row.tray_id, layout) for row in rows}
 
             missing_global = sorted(used_global_trays - assigned_global_trays)
             if not missing_global:
@@ -165,12 +160,13 @@ async def _send_missing_assignment_notification(
 ) -> None:
     """Describe the unassigned trays and push them to the UI and the providers."""
     state = printer_manager.get_status(printer_id)
+    layout = TrayLayout.from_state(state)
     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),
+                "slot": _slot_label_from_global_tray(global_id, layout),
                 "profile": profile,
                 "color": color,
             }

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

@@ -30,6 +30,7 @@ from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 from backend.app.services.ams_assignment_lock import get_ams_assignment_lock, get_spool_move_lock
 from backend.app.utils.tag_normalization import normalize_tag_uid, normalize_tray_uuid
+from backend.app.utils.tray_ids import external_tray_id
 
 logger = logging.getLogger(__name__)
 
@@ -50,7 +51,7 @@ def _live_tray(printer_id: int, ams_id: int, tray_id: int) -> dict | None:
     if ams_id == 255:
         vt_trays = raw.get("vt_tray") or []
         for tray in [vt_trays] if isinstance(vt_trays, dict) else vt_trays:
-            if isinstance(tray, dict) and str(tray.get("id", 254)) == str(254 + tray_id):
+            if isinstance(tray, dict) and str(tray.get("id", 254)) == str(external_tray_id(tray_id)):
                 return tray
         return None
     units = raw.get("ams") or []

+ 37 - 36
backend/app/services/spoolman_tracking.py

@@ -18,6 +18,7 @@ from backend.app.api.routes._spoolman_helpers import spoolman_net_weight
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import async_session
 from backend.app.services import spool_swaps
+from backend.app.services.printer_manager import printer_tray_layout
 from backend.app.services.spoolman import (
     SpoolmanClientError,
     SpoolmanNotFoundError,
@@ -25,6 +26,7 @@ from backend.app.services.spoolman import (
     get_spoolman_client,
     init_spoolman_client,
 )
+from backend.app.utils.tray_ids import EXTERNAL_AMS_ID, TrayLayout, external_slot_index
 
 logger = logging.getLogger(__name__)
 
@@ -74,23 +76,16 @@ def _hash_serial_to_hex32(serial: str) -> str:
     return format(hash_value, "X").zfill(8)
 
 
-def _global_tray_id_to_ams_slot(global_tray_id: int) -> tuple[int, int]:
+def _global_tray_id_to_ams_slot(global_tray_id: int, layout: TrayLayout | None = None) -> tuple[int, int]:
     """Convert global tray id to (ams_id, tray_id) tuple for fallback tag generation."""
-    # External spool slots use IDs 254/255 and map to ams_id=255 tray_id=0/1.
-    if global_tray_id >= 254:
-        return 255, max(0, global_tray_id - 254)
-    # AMS-HT units are addressed by ams_id directly and have a single tray.
-    if global_tray_id >= 128:
-        return global_tray_id, 0
-    # Standard AMS units: four trays each.
-    return global_tray_id // 4, global_tray_id % 4
+    return (layout or TrayLayout()).slot_key(global_tray_id)
 
 
-def _get_fallback_spool_tag(printer_serial: str, global_tray_id: int) -> str:
+def _get_fallback_spool_tag(printer_serial: str, global_tray_id: int, layout: TrayLayout | None = None) -> str:
     """Mirror frontend getFallbackSpoolTag(serial, amsId, trayId) exactly."""
     if not printer_serial:
         return ""
-    ams_id, tray_id = _global_tray_id_to_ams_slot(global_tray_id)
+    ams_id, tray_id = _global_tray_id_to_ams_slot(global_tray_id, layout)
     return get_fallback_spool_tag_for_slot(printer_serial, ams_id, tray_id)
 
 
@@ -118,7 +113,9 @@ def is_slot_fallback_tag(tag: str, printer_serials) -> bool:
     return any(tag[:8] == _hash_serial_to_hex32(s) for s in printer_serials if s)
 
 
-def _resolve_spool_tag(tray_info: dict, printer_serial: str = "", global_tray_id: int | None = None) -> str:
+def _resolve_spool_tag(
+    tray_info: dict, printer_serial: str = "", global_tray_id: int | None = None, layout: TrayLayout | None = None
+) -> str:
     """Get the best spool identifier from tray info (prefer tray_uuid over tag_uid).
 
     Returns empty string if no usable identifier is found.
@@ -131,7 +128,7 @@ def _resolve_spool_tag(tray_info: dict, printer_serial: str = "", global_tray_id
     if tag_uid and tag_uid != _ZERO_TAG_UID and _is_non_zero_identifier(tag_uid):
         return tag_uid
     if global_tray_id is not None:
-        return _get_fallback_spool_tag(printer_serial, global_tray_id)
+        return _get_fallback_spool_tag(printer_serial, global_tray_id, layout)
     return ""
 
 
@@ -326,7 +323,7 @@ def _resolve_slot_to_tray_fallback(
     # fields ``bambu_mqtt`` maintains on the state object itself, so an empty
     # payload must not short-circuit past it.
     if raw_data and not tray_state_only:
-        decoded = _decode_mqtt_mapping(raw_data.get("mapping"))
+        decoded = _decode_mqtt_mapping(raw_data.get("mapping"), TrayLayout.from_state(state))
         if decoded:
             return decoded, "mqtt"
 
@@ -356,12 +353,14 @@ def build_ams_tray_lookup(raw_data: dict) -> dict[int, dict]:
     """
     lookup = {}
     ams_data = raw_data.get("ams", [])
+    layout = TrayLayout.from_units(ams_data if isinstance(ams_data, list) else None)
     for ams_unit in ams_data:
         ams_id = int(ams_unit.get("id", 0))
         for tray in ams_unit.get("tray", []):
             tray_id = int(tray.get("id", 0))
-            # AMS-HT units have IDs starting at 128 with a single tray
-            global_tray_id = ams_id if ams_id >= 128 else ams_id * 4 + tray_id
+            # AMS-HT units have IDs starting at 128 with a single tray; the
+            # H3C's AMS 3H is 28 + slot (see utils.tray_ids)
+            global_tray_id = layout.global_tray_id(ams_id, tray_id)
             lookup[global_tray_id] = {
                 "tray_uuid": tray.get("tray_uuid", ""),
                 "tag_uid": tray.get("tag_uid", ""),
@@ -424,11 +423,12 @@ def _snapshot_tray_remain(raw_data: dict, skipped_out: list[str] | None = None)
         if not isinstance(vt, dict):
             continue
         vt_id = int(vt.get("id", 254))
-        # 254 → (255, 0), 255 → (255, 1) — matches usage_tracker's encoding.
-        vt_tray_id = vt_id - 254
+        # 254 → (255, 0), 255 → (255, 1), a nozzle-rack printer's 247-253 →
+        # (255, 2-8) — matches usage_tracker's encoding.
+        vt_tray_id = external_slot_index(vt_id)
         remain = vt.get("remain", -1)
         if isinstance(remain, int) and 0 <= remain <= 100:
-            snapshot[f"255-{vt_tray_id}"] = {
+            snapshot[f"{EXTERNAL_AMS_ID}-{vt_tray_id}"] = {
                 "remain": remain,
                 "tray_uuid": vt.get("tray_uuid", "") or "",
             }
@@ -1085,6 +1085,7 @@ async def _report_spool_usage_for_slots(
     Returns number of spools successfully updated.
     """
     spools_updated = 0
+    layout = printer_tray_layout(printer_id)
     for slot_id, grams_used in filament_usage_items:
         if grams_used <= 0:
             continue
@@ -1095,7 +1096,7 @@ async def _report_spool_usage_for_slots(
             logger.debug("[SPOOLMAN] Slot %s: no tray at global_tray_id %s", slot_id, global_tray_id)
             continue
 
-        is_external = global_tray_id >= 254
+        is_external = layout.is_external(global_tray_id)
         tray_type = tray_info.get("tray_type", "")
         logger.debug(
             "[SPOOLMAN] Slot %s resolved to global_tray_id %s (tray_type=%s, external=%s)",
@@ -1117,7 +1118,7 @@ async def _report_spool_usage_for_slots(
         # 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)
+        spool_tag = _resolve_spool_tag(tray_info, printer_serial, global_tray_id, layout)
         if spool_tag:
             spool = await client.find_spool_by_tag(spool_tag)
             if spool:
@@ -1128,7 +1129,7 @@ async def _report_spool_usage_for_slots(
                 spool_material = (spool.get("filament") or {}).get("material")
 
         if spool_id_to_use is None and printer_id is not None:
-            ams_id, tray_id = _global_tray_id_to_ams_slot(global_tray_id)
+            ams_id, tray_id = _global_tray_id_to_ams_slot(global_tray_id, layout)
             spool_id_to_use = await _resolve_spool_id_via_slot_assignment(printer_id, ams_id, tray_id)
             if spool_id_to_use is not None:
                 resolution_path = "slot-assignment"
@@ -1137,7 +1138,7 @@ async def _report_spool_usage_for_slots(
         # grams with the one it replaced (#3325); the first is the archive's.
         shares = None
         if swaps is not None:
-            shares = swaps.shares(*_global_tray_id_to_ams_slot(global_tray_id), slot_id, grams_used)
+            shares = swaps.shares(*_global_tray_id_to_ams_slot(global_tray_id, layout), slot_id, grams_used)
             if shares and shares[0][0] != spool_id_to_use:
                 spool_id_to_use = shares[0][0]
                 resolution_path = "swap log"
@@ -1238,6 +1239,7 @@ async def _report_spool_usage_split_by_tray_changes(
 
     spools_updated = 0
     handled_global_tray_ids: set[int] = set()
+    layout = printer_tray_layout(printer_id)
 
     for usage in filament_usage:
         slot_id = usage.get("slot_id", 0)
@@ -1275,7 +1277,7 @@ async def _report_spool_usage_split_by_tray_changes(
             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 ""
+            spool_tag = _resolve_spool_tag(tray_info, printer_serial, tray_global, layout) if tray_info else ""
             if spool_tag:
                 spool = await client.find_spool_by_tag(spool_tag)
                 if spool:
@@ -1286,7 +1288,7 @@ async def _report_spool_usage_split_by_tray_changes(
                     spool_material = (spool.get("filament") or {}).get("material")
 
             if spool_id_to_use is None:
-                seg_ams_id, seg_tray_id = _global_tray_id_to_ams_slot(tray_global)
+                seg_ams_id, seg_tray_id = _global_tray_id_to_ams_slot(tray_global, layout)
                 spool_id_to_use = await _resolve_spool_id_via_slot_assignment(printer_id, seg_ams_id, seg_tray_id)
                 if spool_id_to_use is not None:
                     resolution_path = "slot-assignment"
@@ -1296,7 +1298,7 @@ async def _report_spool_usage_split_by_tray_changes(
             if swaps is not None:
                 is_last = seg_idx + 1 >= len(tray_changes)
                 seg_shares = swaps.shares(
-                    *_global_tray_id_to_ams_slot(tray_global),
+                    *_global_tray_id_to_ams_slot(tray_global, layout),
                     slot_id,
                     segment_grams,
                     tray_changes[seg_idx][1],
@@ -1586,7 +1588,9 @@ async def _report_partial_usage(
                 diameter = 1.75
 
                 if tray_info:
-                    spool_tag = _resolve_spool_tag(tray_info, printer_serial, global_tray_id)
+                    spool_tag = _resolve_spool_tag(
+                        tray_info, printer_serial, global_tray_id, printer_tray_layout(printer_id)
+                    )
                     if spool_tag:
                         spool = await client.find_spool_by_tag(spool_tag)
                         if spool:
@@ -1960,16 +1964,17 @@ def _print_used_tray_keys(
     read into locals beforehand.
     """
     keys: set[tuple[int, int]] = set()
+    layout = TrayLayout.from_state(state)
     for global_tray_id in list(slot_to_tray or []):
         if isinstance(global_tray_id, int) and global_tray_id >= 0:
-            keys.add(_global_tray_id_to_ams_slot(global_tray_id))
+            keys.add(_global_tray_id_to_ams_slot(global_tray_id, layout))
     for change in getattr(state, "tray_change_log", None) or []:
         if isinstance(change, (tuple, list)) and change:
             global_tray_id = change[0]
             if isinstance(global_tray_id, int) and global_tray_id >= 0:
-                keys.add(_global_tray_id_to_ams_slot(global_tray_id))
+                keys.add(_global_tray_id_to_ams_slot(global_tray_id, layout))
     if isinstance(tray_now_at_start, int) and 0 <= tray_now_at_start <= _MAX_REAL_TRAY_ID:
-        keys.add(_global_tray_id_to_ams_slot(tray_now_at_start))
+        keys.add(_global_tray_id_to_ams_slot(tray_now_at_start, layout))
     return keys
 
 
@@ -1996,6 +2001,7 @@ async def _report_remain_delta_for_slots(
     """
     spools_updated = 0
     not_in_print: list[str] = []
+    layout = printer_tray_layout(printer_id)
     for slot_key, start in tray_remain_start.items():
         try:
             ams_id_str, tray_id_str = slot_key.split("-", 1)
@@ -2006,12 +2012,7 @@ async def _report_remain_delta_for_slots(
         # Skip slots already handled by the 3MF path. Encoding mirrors
         # build_ams_tray_lookup: VT trays land at 254/255, AMS-HT keeps
         # its native id (>=128), regular AMS slots are ams_id*4+tray_id.
-        if ams_id == 255:
-            global_tray_id = 254 + tray_id
-        elif ams_id >= 128:
-            global_tray_id = ams_id
-        else:
-            global_tray_id = ams_id * 4 + tray_id
+        global_tray_id = layout.tray_from_slot_key(ams_id, tray_id)
         if global_tray_id in handled_global_tray_ids:
             continue
 

+ 49 - 58
backend/app/services/usage_tracker.py

@@ -21,6 +21,7 @@ from backend.app.models.spool import Spool
 from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.models.spool_usage_history import SpoolUsageHistory
 from backend.app.services import spool_swaps
+from backend.app.utils.tray_ids import EXTERNAL_AMS_ID, TrayLayout, decode_wire_slot, external_slot_index
 
 logger = logging.getLogger(__name__)
 
@@ -31,9 +32,13 @@ def has_second_external_holder(raw_data: dict | None) -> bool:
     ``tray_now`` 255 means "nothing loaded" on a printer with one external
     holder, and the right-hand external spool on one with two (H2D, X2D),
     which lists it in ``vt_tray``. Both inventory modes read it this way
-    (#3405).
+    (#3405). Not on a nozzle-rack printer (H3C): it lists an external input
+    per hotend, 247-255, and its ``tray_now`` 255 is "nothing loaded".
     """
-    for vt in (raw_data or {}).get("vt_tray") or []:
+    vt_trays = (raw_data or {}).get("vt_tray") or []
+    if isinstance(vt_trays, list) and TrayLayout.from_units(None, vt_trays).rack:
+        return False
+    for vt in vt_trays:
         if not isinstance(vt, dict):
             continue
         try:
@@ -62,7 +67,7 @@ def mapping_names_no_used_tray(slot_to_tray: list | None, filament_usage: list[d
     )
 
 
-def _decode_mqtt_mapping(mapping_raw: list | None) -> list[int] | None:
+def _decode_mqtt_mapping(mapping_raw: list | None, layout: TrayLayout | None = None) -> list[int] | None:
     """Decode MQTT mapping field (snow-encoded) to bambuddy global tray IDs.
 
     The printer's MQTT mapping field is an array indexed by slicer filament slot
@@ -77,24 +82,8 @@ def _decode_mqtt_mapping(mapping_raw: list | None) -> list[int] | None:
 
     result = []
     for value in mapping_raw:
-        if not isinstance(value, int) or value >= 65535:
-            result.append(-1)
-            continue
-
-        ams_hw_id = value >> 8
-        slot = value & 0xFF
-
-        if 0 <= ams_hw_id <= 3:
-            # Regular AMS: sequential global ID
-            result.append(ams_hw_id * 4 + (slot & 0x03))
-        elif 128 <= ams_hw_id <= 135:
-            # AMS-HT: global ID is the hardware ID (one slot per unit)
-            result.append(ams_hw_id)
-        elif ams_hw_id in (254, 255):
-            # External spool
-            result.append(254 if slot != 255 else 255)
-        else:
-            result.append(-1)
+        tray = decode_wire_slot(value, layout) if isinstance(value, int) else None
+        result.append(-1 if tray is None else tray)
 
     # Only return if at least one valid mapping exists
     if all(v < 0 for v in result):
@@ -227,6 +216,7 @@ def _match_slots_by_color(
 
     # Build map of normalized color → list of global tray IDs
     color_to_trays: dict[str, list[int]] = {}
+    layout = TrayLayout.from_units(ams_data if isinstance(ams_data, list) else None)
     for ams_unit in ams_data:
         ams_id = int(ams_unit.get("id", 0))
         for tray in ams_unit.get("tray", []):
@@ -237,11 +227,7 @@ def _match_slots_by_color(
                 continue
             # Normalize AMS color: strip alpha (last 2 chars), lowercase
             norm = tray_color[:6].lower() if len(tray_color) >= 6 else tray_color.lower()
-            if ams_id >= 128:
-                global_id = ams_id  # AMS-HT
-            else:
-                global_id = ams_id * 4 + tray_id
-            color_to_trays.setdefault(norm, []).append(global_id)
+            color_to_trays.setdefault(norm, []).append(layout.global_tray_id(ams_id, tray_id))
 
     if not color_to_trays:
         return None
@@ -668,11 +654,11 @@ async def on_print_start(
         if not isinstance(vt, dict):
             continue
         vt_id = int(vt.get("id", 254))
-        # VT tray id 254 → (ams_id=255, tray_id=0), id 255 → (ams_id=255, tray_id=1)
-        vt_tray_id = vt_id - 254
+        # VT tray id 254 → (ams_id=255, tray_id=0), id 255 → (ams_id=255, tray_id=1), 247-253 → 2-8
+        vt_tray_id = external_slot_index(vt_id)
         remain = vt.get("remain", -1)
         if isinstance(remain, int) and 0 <= remain <= 100:
-            tray_remain_start[(255, vt_tray_id)] = remain
+            tray_remain_start[(EXTERNAL_AMS_ID, vt_tray_id)] = remain
         else:
             skipped_invalid.append(f"VT{vt_id}(remain={remain})")
 
@@ -925,12 +911,7 @@ async def on_print_complete(
             # Without this guard, swapping a spool in an UNUSED slot mid-print
             # makes that slot's remain% drop to 0, which the fallback below
             # would otherwise charge to the originally-assigned spool.
-            def _global_to_ams_key(global_tray_id: int) -> tuple[int, int]:
-                if global_tray_id >= 254:
-                    return (255, global_tray_id - 254)
-                if global_tray_id >= 128:
-                    return (global_tray_id, 0)
-                return (global_tray_id // 4, global_tray_id % 4)
+            _global_to_ams_key = TrayLayout.from_state(state).slot_key
 
             print_used_keys: set[tuple[int, int]] = set()
             if ams_mapping:
@@ -970,9 +951,9 @@ async def on_print_complete(
                 if not isinstance(vt, dict):
                     continue
                 vt_id = int(vt.get("id", 254))
-                vt_tray_id = vt_id - 254  # 254→0, 255→1
+                vt_tray_id = external_slot_index(vt_id)  # 254→0, 255→1, rack 247-253→2-8
                 remain = vt.get("remain", -1)
-                trays_to_check.append((255, vt_tray_id, remain, f"VT{vt_id}"))
+                trays_to_check.append((EXTERNAL_AMS_ID, vt_tray_id, remain, f"VT{vt_id}"))
 
             for assign_ams_id, assign_tray_id, current_remain, tray_label in trays_to_check:
                 key = (assign_ams_id, assign_tray_id)
@@ -1532,10 +1513,12 @@ async def _track_from_3mf(
     from backend.app.core.config import settings as app_settings
     from backend.app.models.archive import PrintArchive
     from backend.app.models.print_queue import PrintQueueItem
+    from backend.app.services.printer_manager import printer_tray_layout
     from backend.app.utils.threemf_tools import extract_filament_usage_from_3mf
 
     file_path: Path | None = threemf_path
     archive: PrintArchive | None = None
+    tray_layout = printer_tray_layout(printer_id, printer_manager)
 
     if file_path is None and archive_id:
         result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
@@ -1650,7 +1633,7 @@ async def _track_from_3mf(
         raw_data = getattr(state, "raw_data", None) if state else None
         if raw_data:
             mqtt_mapping = raw_data.get("mapping")
-            decoded = _decode_mqtt_mapping(mqtt_mapping)
+            decoded = _decode_mqtt_mapping(mqtt_mapping, TrayLayout.from_state(state))
             if decoded:
                 slot_to_tray = decoded
                 mapping_source = "mqtt"
@@ -1751,8 +1734,19 @@ async def _track_from_3mf(
     if status == "completed":
         scale = 1.0
     else:
+        from backend.app.utils.printer_models import usage_progress
+
         state = printer_manager.get_status(printer_id)
-        progress = state.progress if state else 0
+        progress = (
+            usage_progress(
+                printer_manager.get_model(printer_id),
+                state.progress,
+                getattr(state, "layer_num", None),
+                getattr(state, "total_layers", None),
+            )
+            if state
+            else 0
+        )
         # Firmware resets progress to 0 on cancel — use last valid progress captured during print
         if progress <= 0 and last_progress > 0:
             progress = last_progress
@@ -1896,15 +1890,7 @@ async def _track_from_3mf(
                     continue
 
                 # Convert global tray ID to (ams_id, tray_id)
-                if tray_global >= 254:
-                    seg_ams_id = 255
-                    seg_tray_id = tray_global - 254
-                elif tray_global >= 128:
-                    seg_ams_id = tray_global
-                    seg_tray_id = 0
-                else:
-                    seg_ams_id = tray_global // 4
-                    seg_tray_id = tray_global % 4
+                seg_ams_id, seg_tray_id = tray_layout.slot_key(tray_global)
 
                 seg_key = (seg_ams_id, seg_tray_id)
                 if seg_key in covered_before:
@@ -2057,16 +2043,7 @@ async def _track_from_3mf(
             if global_tray_id is None:
                 global_tray_id = slot_id - 1
 
-        if global_tray_id >= 254:
-            # External spool: ams_id=255 (sentinel), tray_id=slot index (0 or 1)
-            ams_id = 255
-            tray_id = global_tray_id - 254
-        elif global_tray_id >= 128:
-            ams_id = global_tray_id
-            tray_id = 0
-        else:
-            ams_id = global_tray_id // 4
-            tray_id = global_tray_id % 4
+        ams_id, tray_id = tray_layout.slot_key(global_tray_id)
 
         logger.info(
             "[UsageTracker] 3MF: slot_id=%d -> global_tray=%d -> AMS%d-T%d (used_g=%.1f, tray_now_override=%s)",
@@ -2120,6 +2097,20 @@ async def _track_from_3mf(
             continue
 
         if slot_grams <= 0:
+            if not tray_is_guessed:
+                # The slicer weighs this slot (a 0 g one was skipped above) and
+                # its tray is known, but the print stopped before using any of
+                # it. That is the answer for this tray: left open, the remain%
+                # fallback charged it from the AMS reading at the stop, which
+                # can be a transient 0% and so the whole spool (#3511). A tray
+                # only guessed by position stays the fallback's.
+                handled_trays.add(key)
+                logger.info(
+                    "[UsageTracker] 3MF: AMS%d-T%d used nothing before the print stopped (%s), nothing charged",
+                    ams_id,
+                    tray_id,
+                    status,
+                )
             continue
 
         for share_spool_id, weight_grams in shares or [(spool_id, slot_grams)]:

+ 29 - 6
backend/app/services/virtual_printer/manager.py

@@ -28,7 +28,8 @@ from backend.app.services.virtual_printer.mqtt_bridge import MQTTBridge
 from backend.app.services.virtual_printer.mqtt_server import SimpleMQTTServer
 from backend.app.services.virtual_printer.ssdp_server import SSDPProxy, VirtualPrinterSSDPServer
 from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager, TCPProxy
-from backend.app.utils.printer_models import is_nozzle_rack_model
+from backend.app.utils.printer_models import has_ams3h, is_nozzle_rack_model
+from backend.app.utils.tray_ids import TrayLayout
 
 if TYPE_CHECKING:
     from backend.app.services.printer_manager import PrinterManager
@@ -157,7 +158,9 @@ def _tristate_from_slicer(data: dict, bool_field: str | None, int_field: str) ->
     return None
 
 
-def _extract_slicer_ams_mapping_json(data: dict, log_prefix: str, *, is_dual_nozzle: bool = False) -> str | None:
+def _extract_slicer_ams_mapping_json(
+    data: dict, log_prefix: str, *, is_dual_nozzle: bool = False, layout: TrayLayout | None = None
+) -> str | None:
     """Pull the slicer's own AMS-slot pick out of a captured project_file payload.
 
     BambuStudio/OrcaSlicer resolves the physical AMS tray for each filament
@@ -192,7 +195,8 @@ def _extract_slicer_ams_mapping_json(data: dict, log_prefix: str, *, is_dual_noz
     turns that back into the external-spool entry. Kept as ``-1`` it dispatched
     as unmapped and the printer stopped with 0700-8012 (#3237).
     ``is_dual_nozzle`` is the target printer's, since that decides which
-    global tray an external spool is.
+    global tray an external spool is. So is ``layout``: on a rack printer
+    (H3C) each hotend has its own external input, 247-255.
 
     Returns ``None`` when the field is absent, unparsable, or the classic
     "all -1" unresolved-race sentinel (#2589) — never worth trusting over a
@@ -218,7 +222,7 @@ def _extract_slicer_ams_mapping_json(data: dict, log_prefix: str, *, is_dual_noz
             detail = json.loads(detail)
         except json.JSONDecodeError:
             detail = None
-    raw = resolve_external_spools_in_mapping(raw, detail, is_dual_nozzle)
+    raw = resolve_external_spools_in_mapping(raw, detail, is_dual_nozzle, layout)
     if all(v < 0 for v in raw):
         # #2589 sentinel — every slot unresolved. Let the scheduler compute a
         # fresh mapping from live AMS state instead of trusting this.
@@ -456,6 +460,19 @@ class VirtualPrinterInstance:
             )
         return is_dual_nozzle_model(self.model)
 
+    def _target_tray_layout(self) -> TrayLayout:
+        """How the target printer numbers its trays, which places an H3C's
+        per-hotend external inputs in the slicer's mapping.
+
+        The target's live AMS data, else its model; without a target printer
+        ("any available" queue mode), the VP's own model.
+        """
+        if self._printer_manager is not None and self.target_printer_id is not None:
+            from backend.app.services.printer_manager import printer_tray_layout
+
+            return printer_tray_layout(self.target_printer_id, self._printer_manager)
+        return TrayLayout.ams3h() if has_ams3h(self.model) else TrayLayout()
+
     async def on_print_command(self, filename: str, data: dict) -> None:
         """Handle print command from MQTT.
 
@@ -602,7 +619,10 @@ class VirtualPrinterInstance:
         # #1766 backup gate).
         ams_mapping_json = (
             _extract_slicer_ams_mapping_json(
-                data, f"[VP {self.name}] Late MQTT", is_dual_nozzle=self._target_is_dual_nozzle()
+                data,
+                f"[VP {self.name}] Late MQTT",
+                is_dual_nozzle=self._target_is_dual_nozzle(),
+                layout=self._target_tray_layout(),
             )
             if self.target_printer_id is not None and self.save_ams_mapping
             else None
@@ -1074,7 +1094,10 @@ class VirtualPrinterInstance:
                 ams_mapping_json: str | None = None
                 if slicer_opts is not None and self.target_printer_id is not None and self.save_ams_mapping:
                     ams_mapping_json = _extract_slicer_ams_mapping_json(
-                        slicer_opts, f"[VP {self.name}]", is_dual_nozzle=self._target_is_dual_nozzle()
+                        slicer_opts,
+                        f"[VP {self.name}]",
+                        is_dual_nozzle=self._target_is_dual_nozzle(),
+                        layout=self._target_tray_layout(),
                     )
 
                 # `Force color match` is the user asking Bambuddy to do the

+ 396 - 0
backend/app/services/wled.py

@@ -0,0 +1,396 @@
+"""Optional per-printer WLED preset integration."""
+
+import asyncio
+import logging
+import time
+from dataclasses import dataclass
+
+import httpx
+
+from backend.app.core.tasks import spawn_background_task
+from backend.app.schemas.printer import WLEDConfig
+from backend.app.services.bambu_mqtt import PrinterState
+from backend.app.services.hms_errors import hms_fault_counts
+
+logger = logging.getLogger(__name__)
+
+# While WLED does not answer, the same preset is retried at most this often
+# instead of on every printer status update.
+WLED_RETRY_SECONDS = 30.0
+
+
+class WLEDResponseError(ValueError):
+    """WLED returned JSON that does not match the expected API shape."""
+
+
+def effective_wled_status(
+    state: PrinterState, *, awaiting_plate_clear: bool = False, require_plate_clear: bool = False
+) -> str | None:
+    """Map Bambuddy's existing printer state to a WLED mapping key."""
+    statuses = _applicable_wled_statuses(
+        state, awaiting_plate_clear=awaiting_plate_clear, require_plate_clear=require_plate_clear
+    )
+    return statuses[0] if statuses else None
+
+
+def _applicable_wled_statuses(
+    state: PrinterState, *, awaiting_plate_clear: bool = False, require_plate_clear: bool = False
+) -> list[str]:
+    """Return applicable states in priority order for mapped-preset fallback."""
+    if not state.connected:
+        return ["offline"]
+
+    printer_state = (state.state or "").upper()
+    statuses = []
+    if printer_state == "PAUSE" and state.stg_cur == 6:
+        statuses.append("filament_problem")
+    if any(hms_fault_counts(error) for error in state.hms_errors):
+        statuses.append("hms_error")
+    if require_plate_clear and awaiting_plate_clear and printer_state in {"IDLE", "FINISH", "FAILED"}:
+        statuses.append("queue_waiting")
+    if printer_state == "FAILED":
+        statuses.append("error")
+    elif printer_state == "PAUSE":
+        statuses.append("paused")
+    elif printer_state == "FINISH":
+        statuses.append("finished")
+    elif printer_state in {"RUNNING", "PRINTING"}:
+        statuses.append("printing")
+    elif printer_state in {"PREPARE", "SLICING"}:
+        statuses.append("prepare")
+    elif printer_state == "IDLE":
+        statuses.append("idle")
+    return statuses
+
+
+@dataclass
+class _WLEDRuntime:
+    status: str | None = None
+    preset_id: int | None = None
+    target: tuple[str | None, int | None] | None = None
+    generation: int = 0
+    confirmed_generation: int | None = None
+    finished_timeout_expired: bool = False
+    # Monotonic time before which a failed send is not retried, and whether
+    # the last automatic send failed (so an outage logs once, not per retry).
+    retry_after: float = 0.0
+    unreachable: bool = False
+    send_task: asyncio.Task | None = None
+    finished_task: asyncio.Task | None = None
+
+
+class WLEDManager:
+    """Own WLED HTTP calls, per-printer deduplication, and finished timers."""
+
+    def __init__(self, client: httpx.AsyncClient | None = None):
+        self._client = client
+        self._owns_client = client is None
+        self.require_plate_clear = False
+        self.retry_seconds = WLED_RETRY_SECONDS
+        self._configs: dict[int, WLEDConfig] = {}
+        self._runtime: dict[int, _WLEDRuntime] = {}
+
+    @property
+    def client(self) -> httpx.AsyncClient:
+        if self._client is None:
+            self._client = httpx.AsyncClient(
+                timeout=httpx.Timeout(3.0, connect=2.0),
+                follow_redirects=False,
+            )
+        return self._client
+
+    def configure_printer(self, printer_id: int, config: dict | WLEDConfig | None) -> None:
+        self._cancel_runtime(printer_id, remove=True)
+        try:
+            self._configs[printer_id] = WLEDConfig.model_validate(config or {})
+        except ValueError:
+            self._configs[printer_id] = WLEDConfig()
+            logger.warning("Ignoring invalid WLED configuration for printer %d", printer_id)
+
+    def remove_printer(self, printer_id: int) -> None:
+        self._configs.pop(printer_id, None)
+        self._cancel_runtime(printer_id, remove=True)
+
+    def handle_status(
+        self,
+        printer_id: int,
+        state: PrinterState,
+        *,
+        awaiting_plate_clear: bool = False,
+    ) -> None:
+        """Schedule any needed WLED work without delaying printer handling."""
+        config = self._configs.get(printer_id)
+        if not config or not config.enabled or not config.base_url:
+            self._cancel_runtime(printer_id)
+            return
+
+        status = next(
+            (
+                candidate
+                for candidate in _applicable_wled_statuses(
+                    state, awaiting_plate_clear=awaiting_plate_clear, require_plate_clear=self.require_plate_clear
+                )
+                if getattr(config.presets, candidate) is not None
+            ),
+            None,
+        )
+        preset_id = getattr(config.presets, status, None) if status else None
+        if state.connected and (state.state or "").upper() in {"PREPARE", "SLICING", "RUNNING", "PRINTING"}:
+            self._runtime.setdefault(printer_id, _WLEDRuntime()).finished_timeout_expired = False
+        self._apply_status(printer_id, config, status, preset_id)
+
+    def handle_offline(self, printer_id: int) -> None:
+        """Apply the offline mapping for an explicit printer lifecycle transition."""
+        config = self._configs.get(printer_id)
+        if not config or not config.enabled or not config.base_url:
+            return
+        preset_id = config.presets.offline
+        self._apply_status(printer_id, config, "offline" if preset_id is not None else None, preset_id)
+
+    def _apply_status(self, printer_id: int, config: WLEDConfig, status: str | None, preset_id: int | None) -> None:
+        base_url = config.base_url
+        if not base_url:
+            return
+        idle_preset_id = config.presets.idle
+        runtime = self._runtime.setdefault(printer_id, _WLEDRuntime())
+        expired_finished = runtime.finished_timeout_expired and status == "finished" and idle_preset_id is not None
+        target = (status, preset_id)
+        if runtime.target == target:
+            if expired_finished:
+                if runtime.finished_task is not None and not runtime.finished_task.done():
+                    return
+            elif (
+                preset_id is None
+                or runtime.confirmed_generation == runtime.generation
+                or (runtime.send_task is not None and not runtime.send_task.done())
+            ):
+                return
+            if time.monotonic() < runtime.retry_after:
+                return
+        else:
+            runtime.generation += 1
+            self._cancel_task(runtime.finished_task)
+            runtime.finished_task = None
+            self._cancel_task(runtime.send_task)
+            runtime.target = target
+
+        generation = runtime.generation
+        if expired_finished and idle_preset_id is not None:
+            if runtime.status == "idle" and runtime.preset_id == idle_preset_id:
+                return
+            runtime.finished_task = spawn_background_task(
+                self._send_finished_idle(printer_id, generation, base_url, idle_preset_id),
+                name=f"wled-finished-idle-{printer_id}",
+            )
+            return
+        if preset_id is not None:
+            runtime.send_task = spawn_background_task(
+                self._send_status(printer_id, base_url, status, preset_id, generation),
+                name=f"wled-preset-{printer_id}",
+            )
+
+        if (
+            status == "finished"
+            and config.finished_timeout_seconds
+            and idle_preset_id is not None
+            and runtime.finished_task is None
+        ):
+            runtime.finished_task = spawn_background_task(
+                self._finish_timeout(
+                    printer_id,
+                    generation,
+                    base_url,
+                    idle_preset_id,
+                    config.finished_timeout_seconds,
+                ),
+                name=f"wled-finished-timeout-{printer_id}",
+            )
+
+    async def _send_status(
+        self, printer_id: int, base_url: str, status: str | None, preset_id: int, generation: int
+    ) -> None:
+        ok = await self._send_automatic(printer_id, base_url, preset_id, status)
+        if ok:
+            runtime = self._runtime.get(printer_id)
+            if (
+                runtime
+                and runtime.generation == generation
+                and runtime.target == (status, preset_id)
+                and runtime.send_task is asyncio.current_task()
+            ):
+                runtime.status = status
+                runtime.preset_id = preset_id
+                runtime.confirmed_generation = generation
+
+    async def _send_finished_idle(self, printer_id: int, generation: int, base_url: str, idle_preset_id: int) -> None:
+        if await self._send_automatic(printer_id, base_url, idle_preset_id, "idle"):
+            runtime = self._runtime.get(printer_id)
+            if (
+                runtime
+                and runtime.generation == generation
+                and runtime.finished_timeout_expired
+                and runtime.target
+                and runtime.target[0] == "finished"
+            ):
+                runtime.status = "idle"
+                runtime.preset_id = idle_preset_id
+                runtime.confirmed_generation = generation
+
+    async def _send_automatic(self, printer_id: int, base_url: str, preset_id: int, status: str | None) -> bool:
+        """Send a mapped preset; on failure hold retries back and log once per outage."""
+        runtime = self._runtime.get(printer_id)
+        quiet = runtime is not None and runtime.unreachable
+        ok = await self.send_preset(printer_id, base_url, preset_id, status=status, quiet=quiet)
+        if runtime is None or self._runtime.get(printer_id) is not runtime:
+            return ok
+        if ok:
+            runtime.retry_after = 0.0
+            if runtime.unreachable:
+                runtime.unreachable = False
+                logger.info("[WLED] Printer %d: WLED answers again", printer_id)
+        else:
+            runtime.retry_after = time.monotonic() + self.retry_seconds
+            if not runtime.unreachable:
+                runtime.unreachable = True
+                logger.info(
+                    "[WLED] Printer %d: retrying every %ds until WLED answers", printer_id, int(self.retry_seconds)
+                )
+        return ok
+
+    async def test_preset(self, printer_id: int, base_url: str, preset_id: int) -> bool:
+        """Test a preset while forcing the next status update to restore automatic control."""
+        self._invalidate_automatic(printer_id)
+        try:
+            return await self.send_preset(printer_id, base_url, preset_id)
+        finally:
+            self._invalidate_automatic(printer_id)
+
+    def _invalidate_automatic(self, printer_id: int) -> None:
+        runtime = self._runtime.get(printer_id)
+        if runtime:
+            self._cancel_task(runtime.send_task)
+            runtime.send_task = None
+            runtime.status = None
+            runtime.preset_id = None
+            runtime.confirmed_generation = None
+
+    async def send_preset(
+        self, printer_id: int, base_url: str, preset_id: int, *, status: str | None = None, quiet: bool = False
+    ) -> bool:
+        """Activate a preset. Failures are isolated from all printer work.
+
+        ``quiet`` logs a failure at debug level: a retry during an outage that
+        was already reported.
+        """
+        log_failure = logger.debug if quiet else logger.warning
+        try:
+            response = await self.client.post(f"{base_url}/json/state", json={"ps": preset_id})
+            response.raise_for_status()
+            if not isinstance(response.json(), dict):
+                raise WLEDResponseError("state response must be a JSON object")
+            logger.info("[WLED] Printer %d state=%s -> preset=%d", printer_id, status or "manual", preset_id)
+            return True
+        except asyncio.CancelledError:
+            raise
+        except httpx.HTTPStatusError as exc:
+            log_failure(
+                "[WLED] Printer %d state=%s -> preset=%d failed (HTTP %d)",
+                printer_id,
+                status or "manual",
+                preset_id,
+                exc.response.status_code,
+            )
+        except (httpx.HTTPError, ValueError):
+            log_failure("[WLED] Printer %d state=%s -> preset=%d failed", printer_id, status or "manual", preset_id)
+        return False
+
+    async def list_presets(self, base_url: str) -> list[dict[str, int | str]]:
+        """Return WLED preset names and IDs without modifying WLED state."""
+        response = await self.client.get(f"{base_url}/presets.json")
+        response.raise_for_status()
+        try:
+            payload = response.json()
+        except ValueError as exc:
+            raise WLEDResponseError("presets response is not valid JSON") from exc
+        if not isinstance(payload, dict):
+            raise WLEDResponseError("presets response must be a JSON object")
+
+        presets = []
+        for raw_id, value in payload.items():
+            try:
+                preset_id = int(raw_id)
+            except (TypeError, ValueError):
+                continue
+            if not 1 <= preset_id <= 250 or not isinstance(value, dict):
+                continue
+            name = value.get("n")
+            presets.append(
+                {"id": preset_id, "name": name.strip() if isinstance(name, str) and name.strip() else str(preset_id)}
+            )
+        return sorted(presets, key=lambda item: int(item["id"]))
+
+    async def get_info(self, base_url: str) -> dict[str, str | None]:
+        """Return the optional name and version reported by WLED."""
+        response = await self.client.get(f"{base_url}/json/info")
+        response.raise_for_status()
+        try:
+            payload = response.json()
+        except ValueError as exc:
+            raise WLEDResponseError("info response is not valid JSON") from exc
+        if not isinstance(payload, dict):
+            raise WLEDResponseError("info response must be a JSON object")
+
+        def clean(value) -> str | None:
+            return value.strip() if isinstance(value, str) and value.strip() else None
+
+        return {"name": clean(payload.get("name")), "version": clean(payload.get("ver"))}
+
+    async def shutdown(self) -> None:
+        tasks: list[asyncio.Task] = []
+        for runtime in self._runtime.values():
+            for task in (runtime.send_task, runtime.finished_task):
+                if task and not task.done():
+                    task.cancel()
+                    tasks.append(task)
+        if tasks:
+            await asyncio.gather(*tasks, return_exceptions=True)
+        self._runtime.clear()
+        self._configs.clear()
+        if self._owns_client and self._client is not None:
+            await self._client.aclose()
+        self._client = None
+
+    async def _finish_timeout(
+        self,
+        printer_id: int,
+        generation: int,
+        base_url: str,
+        idle_preset_id: int,
+        timeout_seconds: int,
+    ) -> None:
+        try:
+            await asyncio.sleep(timeout_seconds)
+            runtime = self._runtime.get(printer_id)
+            if not runtime or runtime.generation != generation or not runtime.target or runtime.target[0] != "finished":
+                return
+            runtime.finished_timeout_expired = True
+            await self._send_finished_idle(printer_id, generation, base_url, idle_preset_id)
+        except asyncio.CancelledError:
+            return
+
+    def _cancel_runtime(self, printer_id: int, *, remove: bool = False) -> None:
+        runtime = self._runtime.get(printer_id)
+        if runtime:
+            runtime.generation += 1
+            self._cancel_task(runtime.send_task)
+            self._cancel_task(runtime.finished_task)
+        if remove:
+            self._runtime.pop(printer_id, None)
+
+    @staticmethod
+    def _cancel_task(task: asyncio.Task | None) -> None:
+        if task and not task.done():
+            task.cancel()
+
+
+wled_manager = WLEDManager()

+ 61 - 1
backend/app/utils/printer_models.py

@@ -24,6 +24,7 @@ PRINTER_MODEL_MAP = {
     "Bambu Lab H2S": "H2S",
     "Bambu Lab X2D": "X2D",
     "Bambu Lab A2L": "A2L",
+    "Bambu Lab H3C": "H3C",
 }
 
 # Map from printer_model_id (internal codes in slice_info.config) to short names
@@ -61,6 +62,10 @@ PRINTER_MODEL_ID_MAP = {
     "O1C": "H2C",
     "O1C2": "H2C",
     "O1S": "H2S",
+    # H3 series ("Bambu Lab H3C.json" model_id). One extruder with nine
+    # nozzles in Studio's profile, so not in DUAL_NOZZLE_MODELS or
+    # NOZZLE_RACK_MODELS: the H2C's rack ids and left/right split don't fit it.
+    "N10": "H3C",
 }
 
 
@@ -107,6 +112,7 @@ LINEAR_RAIL_MODELS = frozenset(
         "H2DPRO",
         "H2C",
         "H2S",
+        "H3C",
         # Internal codes
         "N1",  # A1 Mini
         "N2S",  # A1
@@ -120,6 +126,7 @@ LINEAR_RAIL_MODELS = frozenset(
         "O1C",  # H2C
         "O1C2",  # H2C (dual nozzle variant)
         "O1S",  # H2S
+        "N10",  # H3C
     ]
 )
 
@@ -184,9 +191,13 @@ NO_REMOTE_STORAGE_TOGGLE_MODELS = frozenset(
         # Display names (uppercase, no spaces)
         "P1S",
         "P1P",
+        # A2L (#3499): neither the printer (firmware 01.01.05.00) nor Bambu
+        # Studio or OrcaSlicer offers the option, per the reporter.
+        "A2L",
         # Internal codes
         "C11",  # P1P
         "C12",  # P1S
+        "N9",  # A2L
     ]
 )
 
@@ -355,7 +366,8 @@ def has_remote_storage_toggle(model: str | None) -> bool:
 
     False for P1-series (has an SD slot, but no on-printer screen and no
     published `support_save_remote_print_file_to_storage` capability, so the
-    Bambu Studio toggle never renders). The external_storage diagnostic uses
+    Bambu Studio toggle never renders), and for the A2L, which offers the
+    option nowhere (#3499). The external_storage diagnostic uses
     this to skip rather than report an unresolvable fail (#2524). Defaults to
     True for unknown models so the check keeps working on anything not
     explicitly listed.
@@ -374,6 +386,54 @@ def is_dual_nozzle_model(model: str | None) -> bool:
     return normalized in DUAL_NOZZLE_MODELS
 
 
+# Chamber heater runs only in the airduct's heating mode (in cooling mode the
+# H3C acks M141 and stays off; the H2 series heats in either).
+CHAMBER_HEATER_NEEDS_HEATING_MODE_MODELS = frozenset(["H3C", "N10"])
+
+# `mc_percent` counts the start sequence (an H3C is at ~20% on layer 0), so a
+# stopped print is charged by layers instead (usage_progress).
+PROGRESS_INCLUDES_START_SEQUENCE_MODELS = frozenset(["H3C", "N10"])
+
+AMS3H_MODELS = frozenset(["H3C", "N10"])
+
+
+def _model_key(model: str | None) -> str:
+    return model.strip().upper().replace(" ", "").replace("-", "") if isinstance(model, str) else ""
+
+
+def progress_includes_start_sequence(model: str | None) -> bool:
+    """True when the printer's progress percent counts its start sequence (H3C)."""
+    return _model_key(model) in PROGRESS_INCLUDES_START_SEQUENCE_MODELS
+
+
+def has_ams3h(model: str | None) -> bool:
+    """True for a model that comes with an AMS 3H (H3C)."""
+    return _model_key(model) in AMS3H_MODELS
+
+
+def usage_progress(
+    model: str | None,
+    progress: float | int | None,
+    layer_num: int | None,
+    total_layers: int | None,
+) -> float | int:
+    """How much of a stopped print was printed, in percent, for charging its filament.
+
+    The printer's own progress, except on a model whose progress counts the
+    start sequence: there the share of layers printed (0 before layer 1).
+    """
+    if not progress_includes_start_sequence(model):
+        return progress if progress is not None else 0.0
+    if layer_num and layer_num > 0 and total_layers and total_layers > 0:
+        return min(100.0, 100.0 * layer_num / total_layers)
+    return 0.0
+
+
+def chamber_heater_needs_heating_mode(model: str | None) -> bool:
+    """True when a chamber target only takes effect with the airduct in heating mode (H3C)."""
+    return _model_key(model) in CHAMBER_HEATER_NEEDS_HEATING_MODE_MODELS
+
+
 def is_nozzle_rack_model(model: str | None) -> bool:
     """Return True if the model mounts its nozzles from a swappable rack (H2C).
 

+ 390 - 0
backend/app/utils/tray_ids.py

@@ -0,0 +1,390 @@
+"""Bambu Studio's global tray numbering, in one place.
+
+A global tray id names one filament source of a printer: what ``tray_now``
+reports, what a print's ``ams_mapping`` holds and what Bambuddy compares slots
+by. Studio derives it from the unit's AMS type, bits 0-3 of its hex ``info``
+(``DevFilaSystem::GetTrayIndexMap``, ``DevAmsType``)::
+
+    AMS / AMS Lite / AMS 2 Pro (1, 2, 3)   ams_id * 4 + slot
+    A2L AMS Lite (5)                       24 + slot
+    AMS 3H (6, ZMULTI_A)                   28 + slot
+    AMS-B (7, ZMULTI_B)                    37 + slot
+    AMS-HT (4, N3S, units 128-135)         ams_id
+    external input                         its vir_slot id, 254 / 255
+
+On a printer with a direct-connected nozzle rack (the H3C) each hotend has its
+own external input, vir_slot 247-255, and input ``v`` feeds hotend ``255 - v``
+(``DevAmsTray::get_nozzle_addr_id``); rack AMS slot ``k`` feeds hotend ``k``.
+
+A slot is stored as ``(ams_id, tray_id)``, every external input under ams_id
+255 (tray_id 0 = 254, 1 = 255, 2-8 = 247-253). The A2L's AMS Lite is stored as
+unit 6, so ``6 * 4 + slot`` is already Studio's 24-27.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Iterable, Mapping
+from dataclasses import dataclass
+from enum import IntEnum
+
+
+class AmsType(IntEnum):
+    """Bambu Studio's ``DevAmsType``: bits 0-3 of an AMS unit's ``info``."""
+
+    EXT_SPOOL = 0
+    AMS = 1
+    AMS_LITE = 2
+    N3F = 3  # AMS 2 Pro
+    N3S = 4  # AMS-HT
+    AMS_LITE_MIXED = 5  # the A2L's AMS Lite
+    ZMULTI_A = 6  # AMS 3H (H3C)
+    ZMULTI_B = 7  # AMS-B
+
+
+RACK_AMS_TYPES = frozenset({AmsType.ZMULTI_A, AmsType.ZMULTI_B})
+RACK_TRAY_BASE: Mapping[AmsType, int] = {AmsType.ZMULTI_A: 28, AmsType.ZMULTI_B: 37}
+RACK_SLOT_COUNT = 9  # one slot per hotend of the H3C's rack
+AMS_LITE_MIXED_TRAY_BASE = 24
+# The H3C reports its AMS 3H as unit 32; tells the type when ``info`` is missing.
+AMS3H_UNIT_ID = 32
+_AMS3H_TRAY_IDS = range(RACK_TRAY_BASE[AmsType.ZMULTI_A], RACK_TRAY_BASE[AmsType.ZMULTI_A] + RACK_SLOT_COUNT)
+
+AMS_HT_IDS = range(128, 136)
+_REGULAR_WIRE_AMS_IDS = range(4)  # the regular units (ams_id << 8) | slot names
+
+EXTERNAL_AMS_ID = 255
+EXTERNAL_MAIN_ID = 255  # VIRTUAL_TRAY_MAIN_ID
+EXTERNAL_DEPUTY_ID = 254  # VIRTUAL_TRAY_DEPUTY_ID
+RACK_EXTERNAL_IDS = range(247, 256)
+NO_TRAY = 255  # tray_now: nothing loaded
+WIRE_SLOT_NONE = 0xFFFF
+
+
+def _as_int(value: object) -> int | None:
+    """An id field as an int (MQTT sends int or numeric str), else None."""
+    if isinstance(value, bool):
+        return None
+    if isinstance(value, int):
+        return value
+    if isinstance(value, str):
+        try:
+            return int(value.strip())
+        except ValueError:
+            return None
+    return None
+
+
+def ams_type_from_info(info: object) -> AmsType | None:
+    """The AMS type in a unit's ``info`` (hex string, or an already parsed int), else None."""
+    if isinstance(info, bool):
+        return None
+    if isinstance(info, int):
+        raw = info
+    elif isinstance(info, str) and info.strip():
+        try:
+            raw = int(info.strip(), 16)
+        except ValueError:
+            return None
+    else:
+        return None
+    try:
+        return AmsType(raw & 0xF)
+    except ValueError:
+        return None
+
+
+def is_rack_ams(ams_type: object) -> bool:
+    """Whether ``ams_type`` is a rack AMS (AMS 3H / AMS-B): one slot per hotend."""
+    return ams_type in RACK_AMS_TYPES
+
+
+def is_ams_ht_id(ams_id: object) -> bool:
+    """Whether ``ams_id`` is an AMS-HT unit (128-135, one tray each)."""
+    return isinstance(ams_id, int) and not isinstance(ams_id, bool) and ams_id in AMS_HT_IDS
+
+
+def unit_ams_type(ams_id: int, info: object = None) -> AmsType:
+    """The AMS type of unit ``ams_id``: from ``info``, else the id (128-135 AMS-HT, 32 AMS 3H, else AMS)."""
+    if is_ams_ht_id(ams_id):
+        return AmsType.N3S
+    from_info = ams_type_from_info(info)
+    if from_info is not None and from_info != AmsType.EXT_SPOOL:
+        return from_info
+    if ams_id >= 128:
+        return AmsType.N3S
+    if ams_id == AMS3H_UNIT_ID:
+        return AmsType.ZMULTI_A
+    return AmsType.AMS
+
+
+def global_tray_id(ams_id: int, slot: int, ams_type: AmsType | int | None = None) -> int:
+    """Bambu Studio's global tray id of an AMS slot (``ams_type`` defaults to ``unit_ams_type``)."""
+    kind = unit_ams_type(ams_id) if ams_type is None else ams_type
+    if kind == AmsType.N3S:
+        return ams_id
+    if kind == AmsType.AMS_LITE_MIXED:
+        return AMS_LITE_MIXED_TRAY_BASE + slot
+    if kind in RACK_TRAY_BASE:
+        return RACK_TRAY_BASE[AmsType(kind)] + slot
+    return ams_id * 4 + slot
+
+
+def legacy_tray_index(ams_id: int, slot: int) -> int:
+    """The firmware's flat ``ams_id * 4 + slot`` (not a Studio id outside a regular AMS).
+
+    What ``M620 R<n>``, ``ams_filament_setting`` / ``extrusion_cali_set``'s
+    ``tray_id`` on units 0-3 and the A2L AMS Lite's physical tray (64-67) carry.
+    """
+    return ams_id * 4 + slot
+
+
+def unit_label(ams_id: int, ams_type: AmsType | int | None = None) -> str:
+    """Short name of AMS unit ``ams_id`` for alerts and logs: "AMS-A", "HT-A", "AMS 3H"."""
+    kind = unit_ams_type(ams_id) if ams_type is None else ams_type
+    if kind == AmsType.N3S:
+        return f"HT-{chr(65 + (ams_id - 128))}"
+    if kind == AmsType.ZMULTI_A:
+        return "AMS 3H"
+    if kind == AmsType.ZMULTI_B:
+        return f"AMS {ams_id}"
+    return f"AMS-{chr(65 + ams_id)}"
+
+
+def tray_flag_bit(ams_id: int, slot: int, ams_type: AmsType | int | None = None) -> int | None:
+    """Bit of a slot in the ``tray_*_bits`` masks (``DevAmsTray::get_tray_flag_bit``).
+
+    An AMS-HT at ``16 + (ams_id - 128)``, a rack AMS and the AMS Lite at their
+    global id, a regular AMS (units 0-15) at ``ams_id * 4 + slot``; None otherwise.
+    """
+    kind = unit_ams_type(ams_id) if ams_type is None else ams_type
+    if kind == AmsType.N3S:
+        return 16 + (ams_id - 128) if is_ams_ht_id(ams_id) else None
+    if kind == AmsType.AMS_LITE_MIXED or kind in RACK_TRAY_BASE:
+        return global_tray_id(ams_id, slot, kind)
+    if 0 <= ams_id <= 15:
+        return legacy_tray_index(ams_id, slot)
+    return None
+
+
+def is_external_tray(tray_id: object, rack: bool = False) -> bool:
+    """Whether global tray ``tray_id`` is an external input: 254/255, and 247-255 on a ``rack`` printer."""
+    if not isinstance(tray_id, int) or isinstance(tray_id, bool):
+        return False
+    if tray_id in (EXTERNAL_DEPUTY_ID, EXTERNAL_MAIN_ID):
+        return True
+    return rack and tray_id in RACK_EXTERNAL_IDS
+
+
+def external_hotend(tray_id: int) -> int:
+    """The hotend a rack printer's external input ``tray_id`` feeds: ``255 - tray_id``."""
+    return EXTERNAL_MAIN_ID - tray_id
+
+
+def external_slot_index(tray_id: int) -> int:
+    """The ``tray_id`` external input ``tray_id`` is stored under: 254 -> 0, 255 -> 1, 247-253 -> 2-8."""
+    if tray_id in (EXTERNAL_DEPUTY_ID, EXTERNAL_MAIN_ID):
+        return tray_id - EXTERNAL_DEPUTY_ID
+    return tray_id - RACK_EXTERNAL_IDS.start + 2
+
+
+def external_tray_id(slot_index: int) -> int:
+    """The global tray id of the external input stored as ``(255, slot_index)``."""
+    if slot_index in (0, 1):
+        return EXTERNAL_DEPUTY_ID + slot_index
+    return RACK_EXTERNAL_IDS.start + slot_index - 2
+
+
+def _unit_id_and_type(unit: object) -> tuple[int, AmsType] | None:
+    """(id, type) of one unit: a raw MQTT dict, an API dict (``ams_type``) or an object."""
+    if isinstance(unit, Mapping):
+        uid = _as_int(unit.get("id"))
+        explicit = unit.get("ams_type")
+        info = unit.get("info")
+    else:
+        uid = _as_int(getattr(unit, "id", None))
+        explicit = getattr(unit, "ams_type", None)
+        info = getattr(unit, "info", None)
+    if uid is None:
+        return None
+    if isinstance(explicit, int) and not isinstance(explicit, bool) and explicit in AmsType._value2member_map_:
+        kind = AmsType(explicit)
+        if kind != AmsType.EXT_SPOOL:
+            return uid, kind
+    return uid, unit_ams_type(uid, info)
+
+
+@dataclass(frozen=True)
+class TrayLayout:
+    """How one printer numbers its trays: its AMS units' ``(ams_id, type)`` and whether it is a rack printer.
+
+    ``rack``: per-hotend external inputs 247-255 (a rack AMS, or a reported input 247-253).
+    """
+
+    units: tuple[tuple[int, AmsType], ...] = ()
+    rack: bool = False
+
+    @classmethod
+    def from_units(cls, units: Iterable[object] | None, vt_trays: Iterable[object] | None = None) -> TrayLayout:
+        """Layout of an AMS unit list (raw or API units) and a vt_tray list; a unit listed twice counts once."""
+        pairs: list[tuple[int, AmsType]] = []
+        seen: set[int] = set()
+        for unit in units or ():
+            pair = _unit_id_and_type(unit)
+            if pair is not None and pair[0] not in seen:
+                seen.add(pair[0])
+                pairs.append(pair)
+        rack = any(is_rack_ams(kind) for _, kind in pairs)
+        if not rack:
+            for vt in vt_trays or ():
+                vt_id = _as_int(vt.get("id") if isinstance(vt, Mapping) else getattr(vt, "id", None))
+                if vt_id is not None and RACK_EXTERNAL_IDS.start <= vt_id < EXTERNAL_DEPUTY_ID:
+                    rack = True
+                    break
+        return cls(units=tuple(pairs), rack=rack)
+
+    @classmethod
+    def ams3h(cls) -> TrayLayout:
+        """An H3C's layout (its AMS 3H as unit 32), for when its own AMS data is not at hand."""
+        return cls(units=((AMS3H_UNIT_ID, AmsType.ZMULTI_A),), rack=True)
+
+    @classmethod
+    def from_state(cls, state: object, ams3h_model: bool = False) -> TrayLayout:
+        """Layout of a printer's live state; with no AMS data, ``ams3h_model`` (``printer_models.has_ams3h``) gives ``ams3h()``."""
+        raw = getattr(state, "raw_data", None) if state is not None else None
+        layout = cls()
+        if isinstance(raw, Mapping):
+            units = raw.get("ams")
+            if isinstance(units, Mapping):  # also stored as the push's {"ams": [...]}
+                units = units.get("ams")
+            vt_trays = raw.get("vt_tray")
+            if isinstance(vt_trays, Mapping):
+                vt_trays = [vt_trays]
+            layout = cls.from_units(
+                units if isinstance(units, list) else None, vt_trays if isinstance(vt_trays, list) else None
+            )
+        if ams3h_model and not layout.units:
+            return cls.ams3h()
+        return layout
+
+    def ams_type(self, ams_id: int) -> AmsType:
+        """Type of unit ``ams_id``; told from the id when the unit isn't listed."""
+        for uid, kind in self.units:
+            if uid == ams_id:
+                return kind
+        return unit_ams_type(ams_id)
+
+    @property
+    def rack_units(self) -> tuple[tuple[int, AmsType], ...]:
+        """The rack AMS units as ``(ams_id, type)``."""
+        return tuple((uid, kind) for uid, kind in self.units if is_rack_ams(kind))
+
+    def global_tray_id(self, ams_id: int, slot: int) -> int:
+        """Global tray id of slot ``slot`` of unit ``ams_id``."""
+        return global_tray_id(ams_id, slot, self.ams_type(ams_id))
+
+    def is_external(self, tray_id: object) -> bool:
+        """Whether global tray ``tray_id`` is one of this printer's external inputs."""
+        return is_external_tray(tray_id, self.rack)
+
+    def is_loaded_tray(self, tray_id: int) -> bool:
+        """Whether a ``tray_now`` value names a filament source rather than "nothing loaded"."""
+        return (
+            0 <= tray_id <= 15
+            or AMS_LITE_MIXED_TRAY_BASE <= tray_id < AMS_LITE_MIXED_TRAY_BASE + 4
+            or is_ams_ht_id(tray_id)
+            or tray_id == EXTERNAL_DEPUTY_ID
+            or self.rack_slot(tray_id) is not None
+            or (self.is_external(tray_id) and tray_id != NO_TRAY)
+        )
+
+    def rack_slot(self, tray_id: int) -> tuple[int, int] | None:
+        """``(ams_id, slot)`` of a listed rack AMS's slot, else None."""
+        for uid, kind in self.rack_units:
+            base = RACK_TRAY_BASE[kind]
+            if base <= tray_id < base + RACK_SLOT_COUNT:
+                return uid, tray_id - base
+        return None
+
+    def slot_key(self, tray_id: int) -> tuple[int, int]:
+        """The ``(ams_id, tray_id)`` global tray ``tray_id`` is stored under.
+
+        247-255 are only ever external inputs. 28-36 are the AMS 3H's, as in
+        Studio, unless the printer lists a rack AMS or a regular unit 7, so a
+        stored AMS 3H slot keeps its key without live data.
+        """
+        if tray_id in RACK_EXTERNAL_IDS:
+            return EXTERNAL_AMS_ID, external_slot_index(tray_id)
+        if tray_id >= 128:
+            return tray_id, 0
+        rack = self.rack_slot(tray_id)
+        if rack is not None:
+            return rack
+        if tray_id in _AMS3H_TRAY_IDS and not self.rack_units and not any(uid == 7 for uid, _ in self.units):
+            return AMS3H_UNIT_ID, tray_id - _AMS3H_TRAY_IDS.start
+        return tray_id // 4, tray_id % 4
+
+    def tray_from_slot_key(self, ams_id: int, tray_id: int) -> int:
+        """The global tray id of the stored slot ``(ams_id, tray_id)``."""
+        if ams_id == EXTERNAL_AMS_ID:
+            return external_tray_id(tray_id)
+        if ams_id >= 128:
+            return ams_id
+        return self.global_tray_id(ams_id, tray_id)
+
+    def raw_slot_to_global(self, raw: int) -> int | None:
+        """A rack printer's ``tray_now`` / ``tray_tar`` / ``tray_pre`` (the H3C reports the raw slot 0-8).
+
+        None unless the printer has exactly one rack AMS and ``raw`` is one of its slots.
+        """
+        racks = self.rack_units
+        if len(racks) != 1 or not 0 <= raw < RACK_SLOT_COUNT:
+            return None
+        uid, kind = racks[0]
+        return global_tray_id(uid, raw, kind)
+
+
+_NO_LAYOUT = TrayLayout()
+
+
+def decode_wire_slot(value: object, layout: TrayLayout | None = None) -> int | None:
+    """Global tray id of a slot the firmware encodes as ``(ams_id << 8) | slot``.
+
+    ``print.mapping``, ``ams.csl`` and the extruder / nozzle ``snow`` fields use
+    it; 0xFFFF is "none". Units 0-3 (slot bits ``& 3``), the AMS-HT, a rack AMS
+    (the AMS 3H also without a layout), and the external spool: 254/255 give
+    254, or 255 for slot 255; a rack printer's inputs 247-255 give their own id.
+    None when the value names no tray.
+    """
+    encoded = _as_int(value)
+    if encoded is None or not 0 <= encoded < WIRE_SLOT_NONE:
+        return None
+    ams_id, slot = encoded >> 8, encoded & 0xFF
+    layout = layout or _NO_LAYOUT
+    if layout.rack and ams_id in RACK_EXTERNAL_IDS:
+        return ams_id
+    if ams_id in (EXTERNAL_DEPUTY_ID, EXTERNAL_MAIN_ID):
+        return EXTERNAL_MAIN_ID if slot == 255 else EXTERNAL_DEPUTY_ID
+    if is_ams_ht_id(ams_id):
+        return ams_id
+    if ams_id in _REGULAR_WIRE_AMS_IDS:
+        return layout.global_tray_id(ams_id, slot & 0x03)
+    kind = layout.ams_type(ams_id)
+    if is_rack_ams(kind) and slot < RACK_SLOT_COUNT:
+        return global_tray_id(ams_id, slot, kind)
+    return None
+
+
+def mapped_trays_with_slot(mapping_raw: object, local_slot: int) -> dict[int, int]:
+    """``{global tray: ams_id}`` for the regular AMS / AMS-HT trays of a ``print.mapping`` whose local slot is ``local_slot``.
+
+    Places a bare local ``tray_now`` / ``tray_tar`` (0-3) on a printer with several AMS units.
+    """
+    found: dict[int, int] = {}
+    for value in mapping_raw if isinstance(mapping_raw, list) else []:
+        tray = decode_wire_slot(value)
+        if tray is None or is_external_tray(tray):
+            continue
+        ams_id, slot = _NO_LAYOUT.slot_key(tray)
+        if slot == local_slot and (ams_id in _REGULAR_WIRE_AMS_IDS or is_ams_ht_id(ams_id)):
+            found[tray] = ams_id
+    return found

+ 6 - 2
backend/tests/integration/test_git_backup_runs.py

@@ -61,9 +61,13 @@ async def _all_runs(db) -> list[PrintLogEntry]:
 
 
 async def _wipe(db) -> None:
-    """A rebuilt instance: no archives, prints or merges."""
+    """A rebuilt instance: no archives, prints or merges.
+
+    Open runs too: a plain delete of runs leaves them out (_hide_open_runs),
+    and one left behind ties with its restored copy on started_at.
+    """
     for model in (PrintLogEntry, ArchiveAlias, PrintArchive):
-        await db.execute(delete(model))
+        await db.execute(delete(model).execution_options(**{INCLUDE_OPEN_RUNS: True}))
     await db.commit()
 
 

+ 20 - 0
backend/tests/integration/test_print_complete_end_to_end.py

@@ -399,6 +399,26 @@ async def test_the_timelapse_scan_is_for_the_run_that_ended(test_engine, db_sess
     assert schedule.call_args.kwargs["run_id"] == logs[0].id
 
 
+@pytest.mark.parametrize("status", ["failed", "aborted"])
+async def test_a_stopped_prints_timelapse_is_fetched_too(test_engine, db_session, queued_print, status):
+    """#3481: the printer writes the video of a cancelled or failed run, and
+    it is the one video that shows what went wrong."""
+    with patch("backend.app.main._schedule_timelapse_scan") as schedule:
+        await _complete(test_engine, queued_print["printer"], status, extra={"timelapse_was_active": True})
+
+    _archive, _item, logs, _charges = await _rows(db_session, queued_print["archive"], queued_print["item"])
+    schedule.assert_called_once()
+    assert schedule.call_args.args[0] == queued_print["archive"]
+    assert schedule.call_args.kwargs["run_id"] == logs[0].id
+
+
+async def test_no_timelapse_scan_without_a_recording(test_engine, queued_print):
+    with patch("backend.app.main._schedule_timelapse_scan") as schedule:
+        await _complete(test_engine, queued_print["printer"], "aborted")
+
+    schedule.assert_not_called()
+
+
 async def test_the_finish_photo_is_the_ended_runs_too(test_engine, db_session, queued_print):
     from backend.app.core.tasks import spawn_background_task
 

+ 61 - 0
backend/tests/integration/test_printer_scope_1727.py

@@ -92,6 +92,67 @@ async def _listed_ids(async_client: AsyncClient, headers: dict[str, str]) -> set
     return {p["id"] for p in response.json()}
 
 
+@pytest.mark.parametrize(
+    ("endpoint", "method", "result"),
+    [
+        ("presets", "list_presets", [{"id": 3, "name": "Printing"}]),
+        ("test-connection", "get_info", {"name": "WLED", "version": "16.0.0"}),
+        ("test-preset", "send_preset", True),
+    ],
+)
+class TestWLEDPrinterScope:
+    async def test_auth_disabled_allows_wled(self, async_client, printer_factory, endpoint, method, result):
+        printer = await printer_factory(name="WLED")
+        with patch(
+            f"backend.app.api.routes.printers.wled_manager.{method}", new=AsyncMock(return_value=result)
+        ) as call:
+            response = await async_client.post(
+                f"/api/v1/printers/{printer.id}/wled/{endpoint}",
+                json={"base_url": "http://wled.local", "preset_id": 3},
+            )
+        assert response.status_code == 200, response.text
+        call.assert_awaited_once()
+
+    async def test_auth_enabled_requires_printers_update(self, async_client, printer_factory, endpoint, method, result):
+        printer = await printer_factory(name="WLED")
+        admin = await _admin_token(async_client)
+        readers = await _group(async_client, admin, "readers", permissions=["printers:read"], printer_ids=[printer.id])
+        jwt, _ = await _user(async_client, admin, "reader", [readers])
+        url = f"/api/v1/printers/{printer.id}/wled/{endpoint}"
+        body = {"base_url": "http://wled.local", "preset_id": 3}
+        with patch(
+            f"backend.app.api.routes.printers.wled_manager.{method}", new=AsyncMock(return_value=result)
+        ) as call:
+            assert (await async_client.post(url, json=body)).status_code == 401
+            response = await async_client.post(url, headers=_auth(jwt), json=body)
+            assert response.status_code == 403, response.text
+            call.assert_not_awaited()
+
+    async def test_update_permission_is_limited_to_the_groups_printers(
+        self, async_client, printer_factory, endpoint, method, result
+    ):
+        allowed = await printer_factory(name="Allowed")
+        hidden = await printer_factory(name="Hidden")
+        admin = await _admin_token(async_client)
+        permissions = await _group(async_client, admin, "editors", permissions=["printers:update"])
+        team = await _group(async_client, admin, "team", printer_ids=[allowed.id])
+        jwt, _ = await _user(async_client, admin, "editor", [permissions, team])
+        body = {"base_url": "http://wled.local", "preset_id": 3}
+        with patch(
+            f"backend.app.api.routes.printers.wled_manager.{method}", new=AsyncMock(return_value=result)
+        ) as call:
+            denied = await async_client.post(
+                f"/api/v1/printers/{hidden.id}/wled/{endpoint}", headers=_auth(jwt), json=body
+            )
+            assert denied.status_code == 404, denied.text
+            call.assert_not_awaited()
+            response = await async_client.post(
+                f"/api/v1/printers/{allowed.id}/wled/{endpoint}", headers=_auth(jwt), json=body
+            )
+            assert response.status_code == 200, response.text
+            call.assert_awaited_once()
+
+
 class TestVisibility:
     async def test_user_in_no_restricted_group_sees_every_printer(self, async_client, printer_factory):
         a, b = await printer_factory(name="A"), await printer_factory(name="B")

+ 245 - 0
backend/tests/integration/test_printers_api.py

@@ -6,6 +6,7 @@ Tests the full request/response cycle for /api/v1/printers/ endpoints.
 from unittest.mock import AsyncMock, MagicMock, patch
 from urllib.parse import unquote
 
+import httpx
 import pytest
 from httpx import AsyncClient
 from sqlalchemy import select
@@ -75,6 +76,11 @@ class TestPrintersAPI:
             "access_code": "12345678",
             "is_active": True,
             "model": "X1C",
+            "wled_config": {
+                "enabled": True,
+                "base_url": "http://wled.local/",
+                "presets": {"printing": 3},
+            },
         }
 
         response = await async_client.post("/api/v1/printers/", json=data)
@@ -84,6 +90,8 @@ class TestPrintersAPI:
         assert result["name"] == "New Printer"
         assert result["serial_number"] == "00M09A111111111"
         assert result["model"] == "X1C"
+        assert result["wled_config"]["base_url"] == "http://wled.local"
+        assert result["wled_config"]["presets"]["printing"] == 3
 
     @pytest.mark.asyncio
     @pytest.mark.integration
@@ -244,6 +252,36 @@ class TestPrintersAPI:
         assert response.status_code == 200
         assert response.json()["is_active"] is False
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_deactivate_printer_requests_wled_offline(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(is_active=True)
+        with (
+            patch("backend.app.api.routes.printers.printer_manager.disconnect_printer") as disconnect,
+            patch("backend.app.api.routes.printers.wled_manager.handle_offline") as offline,
+        ):
+            response = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"is_active": False})
+        assert response.status_code == 200
+        disconnect.assert_called_once_with(printer.id)
+        offline.assert_called_once_with(printer.id)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_manual_disconnect_requests_wled_offline_without_status_callback(
+        self, async_client: AsyncClient, printer_factory
+    ):
+        printer = await printer_factory(is_active=True)
+        with (
+            patch("backend.app.api.routes.printers.printer_manager.disconnect_printer") as disconnect,
+            patch("backend.app.api.routes.printers.wled_manager.handle_offline") as offline,
+            patch("backend.app.api.routes.printers.printer_manager._on_status_change") as status_callback,
+        ):
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/disconnect")
+        assert response.status_code == 200
+        disconnect.assert_called_once_with(printer.id)
+        offline.assert_called_once_with(printer.id)
+        status_callback.assert_not_called()
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_update_printer_auto_archive(self, async_client: AsyncClient, printer_factory, db_session):
@@ -255,6 +293,155 @@ class TestPrintersAPI:
         assert response.status_code == 200
         assert response.json()["auto_archive"] is False
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_wled_config_round_trip_and_clear(self, async_client: AsyncClient, printer_factory, db_session):
+        printer = await printer_factory(name="WLED Printer")
+        config = {
+            "enabled": True,
+            "base_url": "http://wled.local/",
+            "presets": {"idle": 1, "printing": 3, "finished": 9},
+            "finished_timeout_seconds": 120,
+        }
+
+        with (
+            patch("backend.app.api.routes.printers.printer_manager.disconnect_printer") as disconnect,
+            patch("backend.app.api.routes.printers.printer_manager.connect_printer", new=AsyncMock()) as connect,
+        ):
+            response = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"wled_config": config})
+
+        disconnect.assert_not_called()
+        connect.assert_not_awaited()
+
+        assert response.status_code == 200
+        assert response.json()["wled_config"] == {
+            "enabled": True,
+            "base_url": "http://wled.local",
+            "presets": {
+                "idle": 1,
+                "prepare": None,
+                "printing": 3,
+                "paused": None,
+                "finished": 9,
+                "error": None,
+                "queue_waiting": None,
+                "filament_problem": None,
+                "hms_error": None,
+                "offline": None,
+            },
+            "finished_timeout_seconds": 120,
+        }
+        assert (await async_client.get(f"/api/v1/printers/{printer.id}")).json()["wled_config"] == response.json()[
+            "wled_config"
+        ]
+
+        cleared = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"wled_config": None})
+        assert cleared.status_code == 200
+        assert cleared.json()["wled_config"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_legacy_wled_url_does_not_break_reads_or_weaken_input_validation(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        printer = await printer_factory(name="Legacy WLED")
+        unsafe_url = "http://169.254.169.254/latest/meta-data"
+        printer.wled_config = {"enabled": True, "base_url": unsafe_url, "presets": {"printing": 3}}
+        await db_session.commit()
+
+        listed = await async_client.get("/api/v1/printers/")
+        assert listed.status_code == 200
+        assert next(p for p in listed.json() if p["id"] == printer.id)["wled_config"]["base_url"] == unsafe_url
+
+        detail = await async_client.get(f"/api/v1/printers/{printer.id}")
+        assert detail.status_code == 200
+        assert detail.json()["wled_config"]["base_url"] == unsafe_url
+
+        rejected = await async_client.patch(f"/api/v1/printers/{printer.id}", json={"wled_config": printer.wled_config})
+        assert rejected.status_code == 422
+
+        rejected_create = await async_client.post(
+            "/api/v1/printers/",
+            json={
+                "name": "Unsafe WLED",
+                "serial_number": "00M09A999999999",
+                "ip_address": "192.168.1.99",
+                "access_code": "12345678",
+                "wled_config": printer.wled_config,
+            },
+        )
+        assert rejected_create.status_code == 422
+
+        printer.wled_config = {"enabled": True, "base_url": "http://user:secret@wled.local"}
+        await db_session.commit()
+        redacted = await async_client.get(f"/api/v1/printers/{printer.id}")
+        assert redacted.status_code == 200
+        assert redacted.json()["wled_config"]["base_url"] == "http://user:[REDACTED]@wled.local"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_load_wled_presets(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="WLED Printer")
+        with patch(
+            "backend.app.api.routes.printers.wled_manager.list_presets",
+            new=AsyncMock(return_value=[{"id": 3, "name": "Printing Blue"}]),
+        ) as list_presets:
+            response = await async_client.post(
+                f"/api/v1/printers/{printer.id}/wled/presets",
+                json={"base_url": "http://wled.local/"},
+            )
+
+        assert response.status_code == 200
+        assert response.json() == [{"id": 3, "name": "Printing Blue"}]
+        list_presets.assert_awaited_once_with("http://wled.local")
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_load_wled_presets_reports_offline(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="Offline WLED")
+        with patch(
+            "backend.app.api.routes.printers.wled_manager.list_presets",
+            new=AsyncMock(side_effect=httpx.ConnectError("offline")),
+        ):
+            response = await async_client.post(
+                f"/api/v1/printers/{printer.id}/wled/presets",
+                json={"base_url": "http://wled.local"},
+            )
+
+        assert response.status_code == 502
+        assert response.json()["detail"] == "Could not load WLED presets"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_wled_connection_and_preset_test(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="Test WLED")
+
+        with patch(
+            "backend.app.api.routes.printers.wled_manager.get_info",
+            new=AsyncMock(return_value={"name": "Kitchen LEDs", "version": "0.15.0"}),
+        ) as get_info:
+            response = await async_client.post(
+                f"/api/v1/printers/{printer.id}/wled/test-connection",
+                json={"base_url": "http://wled.local/"},
+            )
+
+        assert response.status_code == 200
+        assert response.json() == {"name": "Kitchen LEDs", "version": "0.15.0"}
+        get_info.assert_awaited_once_with("http://wled.local")
+
+        with patch(
+            "backend.app.api.routes.printers.wled_manager.send_preset",
+            new=AsyncMock(return_value=True),
+        ) as send_preset:
+            response = await async_client.post(
+                f"/api/v1/printers/{printer.id}/wled/test-preset",
+                json={"base_url": "http://wled.local/", "preset_id": 3},
+            )
+
+        assert response.status_code == 200
+        assert response.json() == {"success": True}
+        send_preset.assert_awaited_once_with(printer.id, "http://wled.local", 3)
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_update_nonexistent_printer(self, async_client: AsyncClient):
@@ -1398,6 +1585,64 @@ class TestAMSLoadUnloadAPI:
         client.ams_load_filament.assert_called_once_with(tray_id, extruder_id=None)
         assert response.json()["message"] == f"Loading filament from AMS HT-{'A' if tray_id == 128 else 'H'}"
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_load_names_the_ams3h_slot(self, async_client: AsyncClient, printer_factory):
+        """H3C AMS 3H: global tray 28 + slot, named as its slot 1-9."""
+        from backend.app.utils.tray_ids import TrayLayout
+
+        printer = await printer_factory(name="H3C")
+        client = MagicMock()
+        client.tray_layout = TrayLayout.from_units([{"id": 32, "ams_type": 6}])
+        client.rack_tray_has_filament.return_value = True
+        client.ams_load_filament.return_value = True
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = client
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=28")
+
+        assert response.status_code == 200, response.text
+        client.ams_load_filament.assert_called_once_with(28, extruder_id=None)
+        assert response.json()["message"] == "Loading filament from AMS 3H slot 1"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_load_refuses_an_empty_ams3h_slot(self, async_client: AsyncClient, printer_factory):
+        """An AMS 3H slot has nothing to feed until the AMS pulls the filament in at the inlet."""
+        from backend.app.utils.tray_ids import TrayLayout
+
+        printer = await printer_factory(name="H3C")
+        client = MagicMock()
+        client.tray_layout = TrayLayout.from_units([{"id": 32, "ams_type": 6}])
+        client.rack_tray_has_filament.return_value = False
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = client
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=35")
+
+        assert response.status_code == 409
+        assert "AMS 3H slot 8 has no filament" in response.json()["detail"]
+        client.rack_tray_has_filament.assert_called_once_with(35)
+        client.ams_load_filament.assert_not_called()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize("endpoint", ["load", "unload"])
+    async def test_ams3h_tray_ids_name_nothing_on_another_printer(
+        self, async_client: AsyncClient, printer_factory, endpoint
+    ):
+        """28-36 are AMS 3H slots only on a printer that has one."""
+        printer = await printer_factory(name="X1C")
+        client = MagicMock()
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = client
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/{endpoint}?tray_id=30")
+
+        assert response.status_code == 400
+        client.ams_load_filament.assert_not_called()
+        client.ams_unload_filament.assert_not_called()
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.parametrize("tray_id", [127, 136, 512])

+ 61 - 0
backend/tests/integration/test_restore_windows_paths_3501.py

@@ -0,0 +1,61 @@
+"""The restore converts a Windows backup's paths, and a failure there does not fail it (#3501)."""
+
+import sqlite3
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from backend.app.core.config import settings as app_settings
+
+
+async def _restore(async_client, monkeypatch, tmp_path, convert):
+    from backend.app.api.routes.settings import create_backup_zip
+
+    # Keep the restored database separate from the integration-test engine.
+    db_path = tmp_path / "bambuddy.db"
+    with sqlite3.connect(db_path) as db:
+        db.execute("CREATE TABLE backup_marker (id INTEGER PRIMARY KEY)")
+    monkeypatch.setenv("DATA_DIR", str(tmp_path))
+    monkeypatch.setattr(app_settings, "base_dir", tmp_path)
+    monkeypatch.setattr(app_settings, "database_url", f"sqlite+aiosqlite:///{db_path}")
+    zip_path, _ = await create_backup_zip(output_path=tmp_path)
+
+    with (
+        patch("backend.app.core.database.close_all_connections", new_callable=AsyncMock),
+        patch("backend.app.core.database.reinitialize_database", new_callable=AsyncMock),
+        patch("backend.app.core.database.init_db", new_callable=AsyncMock),
+        patch("backend.app.core.database.invalidate_all_sessions", new_callable=AsyncMock),
+        patch("backend.app.services.print_scheduler.scheduler.stop"),
+        patch("backend.app.services.smart_plug_manager.smart_plug_manager.stop_scheduler"),
+        patch("backend.app.services.notification_service.notification_service.stop_digest_scheduler"),
+        patch("backend.app.services.backup_paths.convert_windows_paths", convert),
+    ):
+        return await async_client.post(
+            "/api/v1/settings/restore",
+            files={"file": ("backup.zip", zip_path.read_bytes(), "application/zip")},
+        )
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+@pytest.mark.sqlite_only  # backs up and restores the app's own SQLite database file
+async def test_the_restore_converts_the_paths(async_client, monkeypatch, tmp_path):
+    convert = AsyncMock(return_value=0)
+
+    response = await _restore(async_client, monkeypatch, tmp_path, convert)
+
+    assert response.status_code == 200, response.text
+    convert.assert_awaited_once()
+    assert convert.await_args.args[1] == tmp_path
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+@pytest.mark.sqlite_only
+async def test_a_failed_conversion_does_not_fail_the_restore(async_client, monkeypatch, tmp_path):
+    convert = AsyncMock(side_effect=RuntimeError("boom"))
+
+    response = await _restore(async_client, monkeypatch, tmp_path, convert)
+
+    assert response.status_code == 200, response.text
+    assert response.json()["success"] is True

+ 14 - 0
backend/tests/integration/test_settings_api.py

@@ -43,6 +43,20 @@ class TestSettingsAPI:
         assert isinstance(result["auto_archive"], bool)
         assert isinstance(result["currency"], str)
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_require_plate_clear_updates_wled_mapping(self, async_client: AsyncClient, monkeypatch):
+        from backend.app.services.wled import wled_manager
+
+        monkeypatch.setattr(wled_manager, "require_plate_clear", False)
+        enabled = await async_client.patch("/api/v1/settings/", json={"require_plate_clear": True})
+        assert enabled.status_code == 200
+        assert wled_manager.require_plate_clear is True
+
+        reset = await async_client.post("/api/v1/settings/reset")
+        assert reset.status_code == 200
+        assert wled_manager.require_plate_clear is False
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_unset_temp_alarm_reads_back_as_null(self, async_client: AsyncClient, db_session):

+ 73 - 6
backend/tests/integration/test_spoolbuddy.py

@@ -960,6 +960,68 @@ class TestWriteTagEndpoints:
         assert spool_data["data_origin"] == "opentag3d"
         assert spool_data["encode_time"] is not None
 
+    async def _write_result(self, async_client: AsyncClient, device_id: str, spool_id: int, tag_uid: str):
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.post(
+                f"{API}/nfc/write-result",
+                json={"device_id": device_id, "spool_id": spool_id, "tag_uid": tag_uid, "success": True},
+            )
+        assert resp.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_written_tag_moves_from_the_spool_that_had_it(
+        self, async_client: AsyncClient, db_session: AsyncSession, device_factory, spool_factory
+    ):
+        """#3505: the tag now holds spool B's data, so spool A gives it up;
+        before, both kept the UID and a scan found A."""
+        device = await device_factory(device_id="sb-move", pending_command="write_tag")
+        spool_a = await spool_factory(tag_uid="04AABB11223344", tag_type="ntag")
+        spool_b = await spool_factory(tag_uid=None)
+
+        # The daemon reports the UID in any case; the stored one is upper case.
+        await self._write_result(async_client, device.device_id, spool_b.id, "04aabb11223344")
+
+        await db_session.refresh(spool_a)
+        await db_session.refresh(spool_b)
+        assert spool_a.tag_uid is None
+        assert spool_b.tag_uid == "04AABB11223344"
+        # A scan of the tag now finds B.
+        lookup = await async_client.get("/api/v1/inventory/spools/by-tag", params={"tag_uid": "04AABB11223344"})
+        assert lookup.status_code == 200
+        assert lookup.json()["id"] == spool_b.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_an_archived_spool_gives_up_the_written_tag(
+        self, async_client: AsyncClient, db_session: AsyncSession, device_factory, spool_factory
+    ):
+        device = await device_factory(device_id="sb-move-arch", pending_command="write_tag")
+        archived = await spool_factory(tag_uid="04AABB11223355", archived_at=datetime.now(timezone.utc))
+        spool = await spool_factory(tag_uid=None)
+
+        await self._write_result(async_client, device.device_id, spool.id, "04AABB11223355")
+
+        await db_session.refresh(archived)
+        assert archived.tag_uid is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_writing_a_spool_its_own_tag_again_keeps_it(
+        self, async_client: AsyncClient, db_session: AsyncSession, device_factory, spool_factory, caplog
+    ):
+        device = await device_factory(device_id="sb-rewrite", pending_command="write_tag")
+        spool = await spool_factory(tag_uid="04AABB11223366", tag_type="ntag")
+
+        with caplog.at_level("INFO", logger="backend.app.api.routes.spoolbuddy"):
+            await self._write_result(async_client, device.device_id, spool.id, "04AABB11223366")
+
+        await db_session.refresh(spool)
+        assert spool.tag_uid == "04AABB11223366"
+        # Nothing moved: the spool is not reported as giving the tag to itself.
+        assert "moved from spool" not in caplog.text
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_write_result_failure_broadcasts_error(
@@ -1081,18 +1143,22 @@ class TestWriteTagEndpoints:
         payload = hb.json()["pending_write_payload"]
         ndef_bytes = bytes.fromhex(payload["ndef_data_hex"])
 
-        # CC bytes
-        assert ndef_bytes[:4] == bytes([0xE1, 0x10, 0x12, 0x00])
-        # TLV type
-        assert ndef_bytes[4] == 0x03
+        # The data area starts with the NDEF TLV, no CC copy (#3500)
+        assert ndef_bytes[0] == 0x03
         # NDEF record: TNF=MIME, type=application/opentag3d
-        assert ndef_bytes[6] == 0xD2
-        assert ndef_bytes[9:30] == b"application/opentag3d"
+        assert ndef_bytes[2] == 0xD2
+        assert ndef_bytes[5:26] == b"application/opentag3d"
         # Terminator
         assert ndef_bytes[-1] == 0xFE
         # Total size fits NTAG213
         assert len(ndef_bytes) <= 144
 
+        # OpenTag3D 2.004 beside it, for a tag with room for it (#3500)
+        v2 = bytes.fromhex(payload["ndef_v2_hex"])
+        assert v2[5:26] == b"application/opentag3d"
+        assert int.from_bytes(v2[26:28], "big") == 2004
+        assert v2[26 + 0x0C : 26 + 0x15] == b"Polymaker"
+
 
 # ============================================================================
 # Scale endpoints
@@ -2180,6 +2246,7 @@ class TestNfcWriteTagSpoolman:
         assert payload["data_origin"] == "spoolman"
         assert payload["spool_id"] == 88
         assert "ndef_data_hex" in payload
+        assert int.from_bytes(bytes.fromhex(payload["ndef_v2_hex"])[26:28], "big") == 2004
 
     @pytest.mark.asyncio
     @pytest.mark.integration

+ 17 - 0
backend/tests/integration/test_spoolman_slot_requests.py

@@ -49,6 +49,23 @@ async def test_every_real_slot_is_accepted(async_client: AsyncClient, printer_id
     assert response.status_code == 200, response.text
 
 
+@pytest.mark.parametrize(("ams_id", "tray_id"), [(32, 8), (255, 8)])
+async def test_slots_4_to_8_of_the_ams3h_and_the_external_inputs_are_accepted(
+    async_client: AsyncClient, printer_id, ams_id, tray_id
+):
+    # The H3C's AMS 3H has nine slots; its per-hotend external inputs 247-253 are (255, 2-8).
+    response = await _assign(async_client, printer_id, ams_id=ams_id, tray_id=tray_id)
+
+    assert response.status_code == 200, response.text
+
+
+@pytest.mark.parametrize(("ams_id", "tray_id"), [(0, 4), (7, 8), (128, 4)])
+async def test_slots_4_to_8_of_any_other_unit_are_refused(async_client: AsyncClient, printer_id, ams_id, tray_id):
+    response = await _assign(async_client, printer_id, ams_id=ams_id, tray_id=tray_id)
+
+    assert response.status_code == 422, response.text
+
+
 async def test_an_archived_spool_is_refused(async_client: AsyncClient, printer_id):
     response = await _assign(async_client, printer_id, spool={**SPOOL, "archived": True})
 

+ 66 - 0
backend/tests/integration/test_startup_creates_every_table.py

@@ -0,0 +1,66 @@
+"""Startup creates the table of every model (#3502).
+
+init_db() imports the model modules so create_all knows their tables. A module
+missing from that list (and from models/__init__) gets no table on a real
+start, while the test suite, which imports every model itself, never notices:
+hms_notified_faults (#3486) was never created that way. So this starts a clean
+Python process, runs init_db() against an empty SQLite database and checks the
+tables against the ones the model files declare.
+"""
+
+import os
+import re
+import sqlite3
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+REPO = Path(__file__).resolve().parents[3]
+MODELS = REPO / "backend" / "app" / "models"
+
+
+def _declared_tables() -> set[str]:
+    tables = set()
+    for path in MODELS.glob("*.py"):
+        source = path.read_text()
+        tables.update(re.findall(r'__tablename__\s*=\s*"(\w+)"', source))
+        tables.update(re.findall(r'Table\(\s*"(\w+)"', source))
+    return tables
+
+
+@pytest.mark.integration
+@pytest.mark.sqlite_only  # inspects the SQLite file the started process creates
+def test_a_fresh_start_creates_every_model_table(tmp_path):
+    db_path = tmp_path / "bambuddy.db"
+    env = {
+        **os.environ,
+        "DATA_DIR": str(tmp_path),
+        "LOG_DIR": str(tmp_path / "logs"),
+        "DATABASE_URL": f"sqlite+aiosqlite:///{db_path}",
+    }
+    result = subprocess.run(
+        [
+            sys.executable,
+            "-c",
+            "import asyncio\n"
+            "from backend.app.core.database import init_db, engine\n"
+            "async def main():\n"
+            "    await init_db()\n"
+            "    await engine.dispose()\n"
+            "asyncio.run(main())\n",
+        ],
+        cwd=REPO,
+        env=env,
+        capture_output=True,
+        text=True,
+        timeout=300,
+    )
+    assert result.returncode == 0, result.stderr[-3000:]
+
+    with sqlite3.connect(db_path) as db:
+        created = {row[0] for row in db.execute("SELECT name FROM sqlite_master WHERE type = 'table'")}
+    declared = _declared_tables()
+    assert "hms_notified_faults" in declared
+    assert declared - created == set()

+ 8 - 0
backend/tests/integration/test_timelapse_editor_job.py

@@ -22,9 +22,17 @@ def _no_jobs():
 
 @pytest.fixture
 def data_dir(tmp_path):
+    import tempfile
+
     from backend.app.api.routes import archives
 
+    # The route's temp files go to the system temp dir, which all test
+    # workers share; their archives all have id 1, so a check for leftover
+    # "*_1_*" files there saw another worker's upload.
+    own_tmp = tmp_path / "tmp"
+    own_tmp.mkdir()
     with (
+        patch.object(tempfile, "tempdir", str(own_tmp)),
         patch.object(archives.settings, "base_dir", tmp_path),
         patch("backend.app.services.timelapse_processor.get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
     ):

+ 9 - 0
backend/tests/integration/test_virtual_printer_api.py

@@ -387,6 +387,15 @@ class TestVirtualPrinterTailscaleToggleAPI:
 class TestVirtualPrinterCaCertificateAPI:
     """Integration tests for GET /api/v1/virtual-printers/ca-certificate."""
 
+    @pytest.fixture(autouse=True)
+    def _own_ca_dir(self, monkeypatch, tmp_path):
+        # The CA lives under the real data dir, which all test workers share.
+        # On a fresh one (Docker) they each created a CA at once and
+        # overwrote one another between two requests of the same test.
+        from backend.app.services.virtual_printer import virtual_printer_manager
+
+        monkeypatch.setattr(virtual_printer_manager, "_base_dir", tmp_path)
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_get_ca_certificate_returns_pem(self, async_client: AsyncClient):

+ 5 - 0
backend/tests/pytest.ini

@@ -14,6 +14,11 @@ filterwarnings =
     # full move to httpx2 is its own change. Match by message text so the
     # filter holds regardless of which warning class starlette uses.
     ignore:Using .httpx. with .starlette.testclient. is deprecated
+    # asyncpg's query-cancel task on PostgreSQL, still pending when a test
+    # worker shuts down and finalized then (KeyError '__import__'), so it is
+    # pinned on whatever test ran last. conftest waits for these per test;
+    # this hides the rest. Only this coroutine: other unraisables still show.
+    ignore:Exception ignored in. <coroutine object Connection._cancel:pytest.PytestUnraisableExceptionWarning
 markers =
     unit: Unit tests (fast, no external deps)
     integration: Integration tests (slower, test full API)

+ 47 - 0
backend/tests/unit/services/test_bambu_mqtt.py

@@ -5586,6 +5586,53 @@ class TestHMSUserActionFiltering:
 
         assert len(mqtt_client.state.hms_errors) == 1
 
+    def test_a_print_error_that_returns_to_zero_is_removed(self, mqtt_client):
+        """#3487: the A1 mini sets print_error for the filament-load prompt and
+        back to 0 once it is answered on the printer, without an hms[] key.
+        The entry stayed until "Clear errors"."""
+        mqtt_client._update_state({"print_error": 0x07FF_C010})
+        assert [e.full_code for e in mqtt_client.state.hms_errors] == ["07FFC010"]
+
+        mqtt_client._update_state({"print_error": 0})
+
+        assert mqtt_client.state.hms_errors == []
+
+    def test_a_new_print_error_replaces_the_previous_one(self, mqtt_client):
+        mqtt_client._update_state({"print_error": 0x07FF_C010})
+        mqtt_client._update_state({"print_error": 0x0500_8061})
+
+        assert [e.full_code for e in mqtt_client.state.hms_errors] == ["05008061"]
+
+    def test_the_same_print_error_again_keeps_its_entry(self, mqtt_client):
+        """A repeat keeps the entry and the job_id it was frozen with."""
+        mqtt_client.state.subtask_id = "job-1"
+        mqtt_client._update_state({"print_error": 0x0500_8061})
+        entry = mqtt_client.state.hms_errors[0]
+        mqtt_client.state.subtask_id = "job-2"
+
+        mqtt_client._update_state({"print_error": 0x0500_8061})
+
+        assert mqtt_client.state.hms_errors == [entry]
+        assert entry.job_id == "job-1"
+
+    def test_an_hms_list_without_print_error_keeps_the_print_error_fault(self, mqtt_client):
+        """print_error comes only when it changes, so an hms[] update between
+        must not drop a fault that is still active."""
+        mqtt_client._update_state({"print_error": 0x07FF_C010})
+
+        mqtt_client._update_state({"hms": []})
+        assert [e.full_code for e in mqtt_client.state.hms_errors] == ["07FFC010"]
+
+        mqtt_client._update_state({"hms": [{"attr": 0x03000100, "code": 0x00018004}]})
+        assert sorted(e.full_code for e in mqtt_client.state.hms_errors) == ["0300010000018004", "07FFC010"]
+
+    def test_an_hms_list_that_now_carries_the_print_error_fault_lists_it_once(self, mqtt_client):
+        mqtt_client._update_state({"print_error": 0x0500_8061})
+
+        mqtt_client._update_state({"hms": [{"attr": 0x05000100, "code": 0x00018061}]})
+
+        assert [e.full_code for e in mqtt_client.state.hms_errors] == ["0500010000018061"]
+
 
 class TestHMSFullCode:
     """full_code is the firmware-matching key for HMS-related commands.

+ 33 - 0
backend/tests/unit/services/test_filament_low_producer_2913.py

@@ -27,6 +27,7 @@ from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 from backend.app.services.print_scheduler import PrintScheduler, _ams_slot_label, _remaining_percent
 from backend.app.services.spoolman import SpoolmanUnavailableError
+from backend.app.utils.tray_ids import TrayLayout
 
 
 async def _printer(db, name: str = "X2D") -> Printer:
@@ -95,6 +96,21 @@ def test_slot_labels_are_the_ones_the_notification_side_already_uses():
     assert _ams_slot_label(6, 0) == "Lite-1"
 
 
+def test_slot_label_names_an_h3c_external_input_ext_n():
+    """An H3C's external inputs are one per hotend, "Ext-n" like Bambu Studio.
+
+    Without the printer's layout, 247-253 read as "HT-" plus a Latin-1 character.
+    """
+    h3c = TrayLayout.ams3h()
+    assert _ams_slot_label(255, 0, h3c) == "Ext-2"
+    assert _ams_slot_label(255, 1, h3c) == "Ext-1"
+    assert _ams_slot_label(255, 2, h3c) == "Ext-9"
+    assert _ams_slot_label(255, 8, h3c) == "Ext-3"
+    assert _ams_slot_label(32, 4, h3c) == "3H-5"
+    # 247-253 are only ever such inputs, so they read so without a layout too.
+    assert _ams_slot_label(255, 4) == "Ext-7"
+
+
 # -- the producer -----------------------------------------------------------
 
 
@@ -145,6 +161,23 @@ async def test_fires_when_an_assigned_spool_is_below_the_threshold(db_session, s
     assert args[3] == 15
 
 
+@pytest.mark.asyncio
+async def test_an_h3c_external_input_alert_names_the_hotends_input(db_session, scheduler, notify):
+    """The alert names the slot with the printer's own layout: an H3C's input (255, 1) is "Ext-1", not "Ext-R"."""
+    printer = await _printer(db_session)
+    await _assigned_spool(db_session, printer, weight_used=850.0, ams=255, tray=1)
+    await db_session.commit()
+
+    with patch(
+        "backend.app.services.print_scheduler.printer_tray_layout",
+        return_value=TrayLayout.ams3h(),
+    ):
+        await _pass(scheduler, db_session)
+
+    notify.on_filament_low.assert_awaited_once()
+    assert notify.on_filament_low.await_args.args[2] == "Ext-1"
+
+
 @pytest.mark.asyncio
 async def test_the_spool_colour_is_passed_for_the_template(db_session, scheduler, notify):
     """on_filament_low has always accepted color= and the filament_low template

+ 97 - 0
backend/tests/unit/services/test_resume_after_startup_pause_3485.py

@@ -0,0 +1,97 @@
+"""A print paused when Bambuddy starts resumes as the same print (#3485).
+
+A power cut takes down the printers and the Bambuddy host together. Bambuddy
+comes back while the printers sit paused in power-loss recovery, so the first
+state it sees is PAUSE, and the printer's PAUSE -> RUNNING was taken for a new
+print: a second archive, left "printing". It is now the restart recovery that
+a print found RUNNING at startup already gets (#1304, #1485).
+"""
+
+import pytest
+
+from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+FILE = "chocolate-bite-buddy_plate_2.3mf"
+
+
+@pytest.fixture
+def client():
+    client = BambuMQTTClient(ip_address="192.168.1.100", serial_number="TEST123", access_code="12345678")
+    client.starts = []
+    client.recovered = []
+    client.completions = []
+    client.on_print_start = client.starts.append
+    client.on_print_running_observed = client.recovered.append
+    client.on_print_complete = client.completions.append
+    return client
+
+
+def _push(client, state: str, file: str | None = FILE) -> None:
+    payload = {"gcode_state": state}
+    if file is not None:
+        payload.update(gcode_file=file, subtask_name=file)
+    client._process_message({"print": payload})
+
+
+def test_the_resume_is_not_a_new_print(client):
+    _push(client, "PAUSE")
+    # The resume frame carries no file name, as on the reporter's A1 mini.
+    _push(client, "RUNNING", file=None)
+
+    assert client.starts == []
+    assert len(client.recovered) == 1
+    assert client.recovered[0]["filename"] == FILE
+
+
+def test_the_resumed_print_still_completes(client):
+    _push(client, "PAUSE")
+    _push(client, "RUNNING", file=None)
+    _push(client, "FINISH")
+
+    assert [c["status"] for c in client.completions] == ["completed"]
+
+
+def test_a_later_pause_and_resume_is_still_the_same_print(client):
+    _push(client, "PAUSE")
+    _push(client, "RUNNING", file=None)
+    _push(client, "PAUSE")
+    _push(client, "RUNNING")
+
+    assert client.starts == []
+
+
+def test_a_print_after_the_paused_one_ends_is_new(client):
+    _push(client, "PAUSE")
+    _push(client, "IDLE", file="")
+    _push(client, "RUNNING", file="next.3mf")
+
+    assert len(client.starts) == 1
+    assert client.starts[0]["filename"] == "next.3mf"
+
+
+def test_a_next_print_paused_before_it_runs_is_still_new(client):
+    _push(client, "PAUSE")
+    _push(client, "IDLE", file="")
+    _push(client, "PREPARE", file="next.3mf")
+    _push(client, "PAUSE", file="next.3mf")
+    _push(client, "RUNNING", file="next.3mf")
+
+    assert [s["filename"] for s in client.starts] == ["next.3mf"]
+
+
+def test_a_print_started_after_startup_is_new(client):
+    _push(client, "IDLE", file="")
+    _push(client, "RUNNING")
+
+    assert len(client.starts) == 1
+    assert client.recovered == []
+
+
+def test_a_pause_seen_while_running_resumes_quietly(client):
+    _push(client, "IDLE", file="")
+    _push(client, "RUNNING")
+    _push(client, "PAUSE")
+    _push(client, "RUNNING")
+
+    assert len(client.starts) == 1
+    assert client.recovered == []

+ 77 - 0
backend/tests/unit/services/test_vp_external_spool_mapping_3237.py

@@ -143,3 +143,80 @@ async def test_queue_item_and_archive_keep_left_external_spool(tmp_path):
     assert len(added_items) == 1
     assert json.loads(added_items[0].ams_mapping) == [254, 3, 2]
     assert archive_print.await_args.kwargs["slicer_ams_mapping"] == [254, 3, 2]
+
+
+# An H3C job: AMS 3H slot 1, then the external inputs of hotends 0, 1 and 5.
+H3C_PAYLOAD = {
+    "command": "project_file",
+    "ams_mapping": [28, -1, -1, -1],
+    "ams_mapping2": [
+        {"ams_id": 32, "slot_id": 0},
+        {"ams_id": 255, "slot_id": 0},
+        {"ams_id": 254, "slot_id": 0},
+        {"ams_id": 250, "slot_id": 0},
+    ],
+}
+
+
+class TestRackPrinterExternalInputs:
+    """An H3C has one external input per hotend (247-255); each keeps its own id."""
+
+    def test_rack_layout_keeps_each_input(self):
+        from backend.app.utils.tray_ids import TrayLayout
+
+        raw = _extract_slicer_ams_mapping_json(H3C_PAYLOAD, "[test]", layout=TrayLayout.ams3h())
+        assert json.loads(raw) == [28, 255, 254, 250]
+
+    def test_target_h3c_layout_from_its_model(self, tmp_path):
+        manager = _manager_with(None)
+        manager.get_status = MagicMock(return_value=None)
+        manager.get_model = MagicMock(return_value="H3C")
+        assert _instance(tmp_path, model="N10", printer_manager=manager)._target_tray_layout().rack
+
+    def test_any_available_h3c_uses_the_vp_model(self, tmp_path):
+        inst = _instance(tmp_path, model="N10")
+        inst.target_printer_id = None
+        assert inst._target_tray_layout().rack
+
+    def test_other_targets_are_not_rack(self, tmp_path):
+        manager = _manager_with(None)
+        manager.get_status = MagicMock(return_value=None)
+        manager.get_model = MagicMock(return_value="H2C")
+        assert not _instance(tmp_path, printer_manager=manager)._target_tray_layout().rack
+        inst = _instance(tmp_path, model="BL-P001")
+        inst.target_printer_id = None
+        assert not inst._target_tray_layout().rack
+
+    @pytest.mark.asyncio
+    async def test_queue_item_keeps_h3c_external_inputs(self, tmp_path):
+        added_items = []
+        mock_db = AsyncMock()
+        mock_db.add = MagicMock(side_effect=added_items.append)
+        mock_db.commit = AsyncMock()
+        session_ctx = AsyncMock()
+        session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
+        session_ctx.__aexit__ = AsyncMock(return_value=False)
+        session_factory = MagicMock(return_value=session_ctx)
+
+        manager = _manager_with(MagicMock(_is_dual_nozzle=False, model="N10"))
+        manager.get_status = MagicMock(return_value=None)
+        manager.get_model = MagicMock(return_value="N10")
+        inst = _instance(tmp_path, model="N10", printer_manager=manager, session_factory=session_factory)
+
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(b"fake3mf")
+        await inst.on_print_command(file_path.name, dict(H3C_PAYLOAD))
+        mock_archive = MagicMock(id=1, print_name="test")
+        with (
+            patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None),
+            patch(
+                "backend.app.services.archive.ArchiveService.archive_print",
+                new_callable=AsyncMock,
+                return_value=mock_archive,
+            ) as archive_print,
+        ):
+            await inst._add_to_print_queue(file_path, "192.168.1.100")
+
+        assert len(added_items) == 1
+        assert json.loads(added_items[0].ams_mapping) == [28, 255, 254, 250]
+        assert archive_print.await_args.kwargs["slicer_ams_mapping"] == [28, 255, 254, 250]

+ 860 - 0
backend/tests/unit/services/test_wled.py

@@ -0,0 +1,860 @@
+import asyncio
+import logging
+
+import httpx
+import pytest
+
+from backend.app.services.bambu_mqtt import HMSError, PrinterState
+from backend.app.services.wled import WLEDManager, WLEDResponseError, effective_wled_status
+
+
+def _state(state: str, **values) -> PrinterState:
+    return PrinterState(connected=values.pop("connected", True), state=state, **values)
+
+
+def _manager(handler) -> tuple[WLEDManager, httpx.AsyncClient]:
+    client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
+    return WLEDManager(client), client
+
+
+def test_effective_status_uses_existing_bambuddy_states_and_priorities():
+    assert effective_wled_status(_state("IDLE")) == "idle"
+    assert effective_wled_status(_state("RUNNING")) == "printing"
+    assert effective_wled_status(_state("PRINTING")) == "printing"
+    assert effective_wled_status(_state("PREPARE")) == "prepare"
+    assert effective_wled_status(_state("SLICING")) == "prepare"
+    assert effective_wled_status(_state("PAUSE")) == "paused"
+    assert effective_wled_status(_state("FINISH")) == "finished"
+    assert effective_wled_status(_state("FAILED")) == "error"
+    assert effective_wled_status(_state("IDLE"), awaiting_plate_clear=True, require_plate_clear=True) == "queue_waiting"
+    assert effective_wled_status(_state("PAUSE", stg_cur=6)) == "filament_problem"
+    assert effective_wled_status(_state("PAUSE", stg_cur=16)) == "paused"
+    assert effective_wled_status(_state("PAUSE", stg_cur=16, ams_status_main=1, mc_print_sub_stage=3)) == "paused"
+    assert (
+        effective_wled_status(_state("PAUSE", stg_cur=6, ams_status_main=0, mc_print_sub_stage=0)) == "filament_problem"
+    )
+    fault = HMSError(code="0300_0008", attr=0x03000200, module=3, severity=1, description="Nozzle temperature abnormal")
+    assert effective_wled_status(_state("IDLE", hms_errors=[fault])) == "hms_error"
+    assert effective_wled_status(_state("RUNNING", connected=False)) == "offline"
+    assert effective_wled_status(_state("UNKNOWN")) is None
+
+
+@pytest.mark.parametrize(
+    ("state", "awaiting_plate_clear", "require_plate_clear", "expected"),
+    [
+        ("FINISH", True, False, "finished"),
+        ("FAILED", True, False, "error"),
+        ("IDLE", True, False, "idle"),
+        ("FINISH", True, True, "queue_waiting"),
+        ("FAILED", True, True, "queue_waiting"),
+        ("IDLE", True, True, "queue_waiting"),
+        ("FINISH", False, True, "finished"),
+        ("FAILED", False, True, "error"),
+        ("RUNNING", True, True, "printing"),
+        ("PRINTING", True, True, "printing"),
+        ("PREPARE", True, True, "prepare"),
+        ("SLICING", True, True, "prepare"),
+        ("PAUSE", True, True, "paused"),
+        ("UNKNOWN", True, True, None),
+    ],
+)
+def test_plate_gate_precedes_only_idle_finished_and_error(state, awaiting_plate_clear, require_plate_clear, expected):
+    assert (
+        effective_wled_status(
+            _state(state), awaiting_plate_clear=awaiting_plate_clear, require_plate_clear=require_plate_clear
+        )
+        == expected
+    )
+
+
+@pytest.mark.parametrize(
+    ("severity", "description", "actions", "full_code", "expected"),
+    [
+        (0, "Invalid level", ["OK_BUTTON"], "0500000000004038", "printing"),
+        (2, None, [], "0C0001000002001B", "printing"),
+        (3, "The top cover is open", [], "0300970000030001", "printing"),
+        (1, "Nozzle temperature abnormal", [], "0300020000010008", "hms_error"),
+        (2, "Filament fault", [], "0500060000020005", "hms_error"),
+        (3, None, ["OK_BUTTON"], "0300970000030001", "hms_error"),
+        (3, "Unable to start drying", [], "1880C003", "hms_error"),
+    ],
+)
+def test_hms_filter_matches_notifications(severity, description, actions, full_code, expected):
+    fault = HMSError(
+        code="fault", attr=0, module=0, severity=severity, description=description, actions=actions, full_code=full_code
+    )
+    assert effective_wled_status(_state("RUNNING", hms_errors=[fault])) == expected
+
+
+def test_specific_fault_priorities_are_preserved_with_plate_gate():
+    ignored = HMSError(code="ignored", attr=0, module=0, severity=0)
+    fault = HMSError(code="fault", attr=0, module=0, severity=2, description="Filament fault")
+    for state in ("RUNNING", "FINISH", "FAILED", "IDLE", "PAUSE"):
+        assert (
+            effective_wled_status(
+                _state(state, hms_errors=[ignored, fault]), awaiting_plate_clear=True, require_plate_clear=True
+            )
+            == "hms_error"
+        )
+    assert (
+        effective_wled_status(
+            _state("PAUSE", stg_cur=6, hms_errors=[fault]), awaiting_plate_clear=True, require_plate_clear=True
+        )
+        == "filament_problem"
+    )
+    assert effective_wled_status(_state("PAUSE", stg_cur=16, hms_errors=[fault])) == "hms_error"
+    assert (
+        effective_wled_status(
+            _state("FINISH", connected=False, hms_errors=[fault]), awaiting_plate_clear=True, require_plate_clear=True
+        )
+        == "offline"
+    )
+
+
+@pytest.mark.asyncio
+async def test_list_presets_returns_only_valid_named_slots():
+    manager, client = _manager(
+        lambda request: httpx.Response(
+            200,
+            json={"0": {}, "3": {"n": "Printing Blue"}, "7": {"n": ""}, "251": {"n": "No"}},
+        )
+    )
+    assert await manager.list_presets("http://wled.local") == [
+        {"id": 3, "name": "Printing Blue"},
+        {"id": 7, "name": "7"},
+    ]
+    await client.aclose()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "response",
+    [httpx.Response(200, text="not-json"), httpx.Response(200, json=[])],
+)
+async def test_list_presets_rejects_invalid_responses(response):
+    manager, client = _manager(lambda request: response)
+    with pytest.raises(WLEDResponseError):
+        await manager.list_presets("http://wled.local")
+    await client.aclose()
+
+
+@pytest.mark.asyncio
+async def test_get_info_returns_optional_wled_identity_and_rejects_invalid_response():
+    manager, client = _manager(lambda request: httpx.Response(200, json={"name": " Kitchen LEDs ", "ver": "0.15.0"}))
+    assert await manager.get_info("http://wled.local") == {"name": "Kitchen LEDs", "version": "0.15.0"}
+    await client.aclose()
+
+    invalid, invalid_client = _manager(lambda request: httpx.Response(200, json=[]))
+    with pytest.raises(WLEDResponseError):
+        await invalid.get_info("http://wled.local")
+    await invalid_client.aclose()
+
+
+@pytest.mark.asyncio
+async def test_send_preset_posts_expected_payload_and_handles_failures(caplog):
+    caplog.set_level(logging.INFO, logger="backend.app.services.wled")
+    requests = []
+
+    def handler(request: httpx.Request):
+        requests.append(request)
+        return httpx.Response(200, json={"success": True})
+
+    manager, client = _manager(handler)
+    assert await manager.send_preset(1, "http://wled.local", 3) is True
+    assert requests[0].url == httpx.URL("http://wled.local/json/state")
+    assert requests[0].content == b'{"ps":3}'
+    assert (logging.INFO, "[WLED] Printer 1 state=manual -> preset=3") in [
+        (record.levelno, record.message) for record in caplog.records
+    ]
+    caplog.clear()
+
+    for response in (httpx.Response(500), httpx.Response(200, text="bad")):
+        failing, failing_client = _manager(lambda request, result=response: result)
+        assert await failing.send_preset(1, "http://wled.local", 3) is False
+        await failing_client.aclose()
+
+    offline, offline_client = _manager(lambda request: (_ for _ in ()).throw(httpx.ConnectError("offline")))
+    assert await offline.send_preset(1, "http://wled.local", 3) is False
+    records = [record for record in caplog.records if record.name == "backend.app.services.wled"]
+    assert len(records) == 3
+    assert all(record.levelno == logging.WARNING and "preset=3 failed" in record.message for record in records)
+    assert all("wled.local" not in record.message for record in records)
+    await offline_client.aclose()
+    await client.aclose()
+
+
+@pytest.mark.asyncio
+async def test_status_logging_follows_successful_sends_without_poll_duplicates(caplog):
+    caplog.set_level(logging.INFO, logger="backend.app.services.wled")
+    manager, client = _manager(lambda request: httpx.Response(200, json={"success": True}))
+    manager.configure_printer(
+        1,
+        {
+            "enabled": True,
+            "base_url": "http://wled.local",
+            "presets": {"printing": 32, "finished": 34, "queue_waiting": 36},
+        },
+    )
+    manager.require_plate_clear = True
+    for state, awaiting in (("RUNNING", False), ("FINISH", False), ("FINISH", True)):
+        manager.handle_status(1, _state(state), awaiting_plate_clear=awaiting)
+        await asyncio.sleep(0)
+        manager.handle_status(1, _state(state), awaiting_plate_clear=awaiting)
+        await asyncio.sleep(0)
+    assert [
+        (record.levelno, record.message) for record in caplog.records if record.name == "backend.app.services.wled"
+    ] == [
+        (logging.INFO, "[WLED] Printer 1 state=printing -> preset=32"),
+        (logging.INFO, "[WLED] Printer 1 state=finished -> preset=34"),
+        (logging.INFO, "[WLED] Printer 1 state=queue_waiting -> preset=36"),
+    ]
+    await manager.shutdown()
+    await client.aclose()
+
+
+@pytest.mark.asyncio
+async def test_cancelled_preset_send_does_not_log_a_switch_or_failure(caplog):
+    caplog.set_level(logging.INFO, logger="backend.app.services.wled")
+
+    async def handler(request):
+        raise asyncio.CancelledError
+
+    manager, client = _manager(handler)
+    with pytest.raises(asyncio.CancelledError):
+        await manager.send_preset(1, "http://wled.local", 32, status="printing")
+    assert not [record for record in caplog.records if record.name == "backend.app.services.wled"]
+    await client.aclose()
+
+
+@pytest.mark.asyncio
+async def test_status_changes_are_deduplicated_and_missing_or_disabled_mappings_do_nothing():
+    sent = []
+
+    def handler(request: httpx.Request):
+        sent.append(request)
+        return httpx.Response(200, json={"success": True})
+
+    manager, client = _manager(handler)
+    manager.configure_printer(
+        1,
+        {"enabled": True, "base_url": "http://wled.local", "presets": {"printing": 3, "paused": 7}},
+    )
+    manager.handle_status(1, _state("RUNNING"))
+    manager.handle_status(1, _state("RUNNING", progress=50))
+    await asyncio.sleep(0)
+    assert len(sent) == 1
+
+    manager.handle_status(1, _state("PAUSE"))
+    await asyncio.sleep(0)
+    assert len(sent) == 2
+
+    manager.handle_status(1, _state("IDLE"))
+    await asyncio.sleep(0)
+    assert len(sent) == 2
+
+    manager.configure_printer(2, {"enabled": False, "base_url": "http://wled.local", "presets": {"idle": 1}})
+    manager.handle_status(2, _state("IDLE"))
+    await asyncio.sleep(0)
+    assert len(sent) == 2
+    await manager.shutdown()
+    await client.aclose()
+
+
+@pytest.mark.asyncio
+async def test_failed_status_send_retries_after_the_interval_then_deduplicates_success(caplog, monkeypatch):
+    caplog.set_level(logging.INFO, logger="backend.app.services.wled")
+    now = [1000.0]
+    monkeypatch.setattr("backend.app.services.wled.time.monotonic", lambda: now[0])
+    sent = []
+
+    def handler(request: httpx.Request):
+        sent.append(request)
+        return httpx.Response(503 if len(sent) <= 2 else 200, json={"success": True})
+
+    manager, client = _manager(handler)
+    manager.configure_printer(1, {"enabled": True, "base_url": "http://wled.local", "presets": {"printing": 3}})
+
+    manager.handle_status(1, _state("RUNNING"))
+    await manager._runtime[1].send_task
+    assert len(sent) == 1
+    assert manager._runtime[1].status is None
+    assert manager._runtime[1].preset_id is None
+
+    # Status updates arrive every second or so: none of them retries before
+    # the interval is over.
+    for _ in range(5):
+        now[0] += 1
+        manager.handle_status(1, _state("RUNNING"))
+        await asyncio.sleep(0)
+    assert len(sent) == 1
+
+    now[0] += 30
+    manager.handle_status(1, _state("RUNNING"))
+    await manager._runtime[1].send_task
+    assert len(sent) == 2
+
+    now[0] += 30
+    manager.handle_status(1, _state("RUNNING"))
+    await manager._runtime[1].send_task
+    assert len(sent) == 3
+    assert (manager._runtime[1].status, manager._runtime[1].preset_id) == ("printing", 3)
+
+    manager.handle_status(1, _state("RUNNING"))
+    await asyncio.sleep(0)
+    assert len(sent) == 3
+    messages = [
+        (record.levelno, record.message) for record in caplog.records if record.name == "backend.app.services.wled"
+    ]
+    # One warning for the whole outage, not one per retry.
+    assert messages == [
+        (logging.WARNING, "[WLED] Printer 1 state=printing -> preset=3 failed (HTTP 503)"),
+        (logging.INFO, "[WLED] Printer 1: retrying every 30s until WLED answers"),
+        (logging.INFO, "[WLED] Printer 1 state=printing -> preset=3"),
+        (logging.INFO, "[WLED] Printer 1: WLED answers again"),
+    ]
+    assert all("wled.local" not in message for _, message in messages)
+    await manager.shutdown()
+    await client.aclose()
+
+
+@pytest.mark.asyncio
+async def test_a_new_state_is_sent_at_once_during_an_outage_without_a_new_warning(caplog, monkeypatch):
+    caplog.set_level(logging.INFO, logger="backend.app.services.wled")
+    monkeypatch.setattr("backend.app.services.wled.time.monotonic", lambda: 1000.0)
+    sent = []
+
+    def handler(request: httpx.Request):
+        sent.append(request.content)
+        return httpx.Response(503)
+
+    manager, client = _manager(handler)
+    manager.configure_printer(
+        1, {"enabled": True, "base_url": "http://wled.local", "presets": {"printing": 3, "paused": 7}}
+    )
+    manager.handle_status(1, _state("RUNNING"))
+    await manager._runtime[1].send_task
+    manager.handle_status(1, _state("PAUSE"))
+    await manager._runtime[1].send_task
+    assert sent == [b'{"ps":3}', b'{"ps":7}']
+    warnings = [record for record in caplog.records if record.levelno == logging.WARNING]
+    assert len(warnings) == 1
+    await manager.shutdown()
+    await client.aclose()
+
+
+@pytest.mark.asyncio
+async def test_identical_status_does_not_start_parallel_sends():
+    started = asyncio.Event()
+    release = asyncio.Event()
+    sent = []
+
+    async def handler(request: httpx.Request):
+        sent.append(request)
+        started.set()
+        await release.wait()
+        return httpx.Response(200, json={"success": True})
+
+    manager, client = _manager(handler)
+    manager.configure_printer(1, {"enabled": True, "base_url": "http://wled.local", "presets": {"printing": 3}})
+    manager.handle_status(1, _state("RUNNING"))
+    task = manager._runtime[1].send_task
+    await asyncio.wait_for(started.wait(), 1)
+
+    manager.handle_status(1, _state("RUNNING", progress=20))
+    manager.handle_status(1, _state("RUNNING", progress=30))
+    assert manager._runtime[1].send_task is task
+    assert manager._runtime[1].status is None
+    assert len(sent) == 1
+
+    release.set()
+    await task
+    assert (manager._runtime[1].status, manager._runtime[1].preset_id) == ("printing", 3)
+    await manager.shutdown()
+    await client.aclose()
+
+
+@pytest.mark.asyncio
+async def test_failed_return_to_previously_confirmed_state_still_retries():
+    sent = []
+
+    def handler(request: httpx.Request):
+        sent.append(request)
+        return httpx.Response(503 if len(sent) == 2 else 200, json={"success": True})
+
+    manager, client = _manager(handler)
+    manager.retry_seconds = 0
+    manager.configure_printer(
+        1,
+        {"enabled": True, "base_url": "http://wled.local", "presets": {"printing": 3, "paused": 7}},
+    )
+    manager.handle_status(1, _state("RUNNING"))
+    await manager._runtime[1].send_task
+    assert manager._runtime[1].status == "printing"
+
+    manager.handle_status(1, _state("PAUSE"))
+    manager.handle_status(1, _state("RUNNING"))
+    await manager._runtime[1].send_task
+    assert len(sent) == 2
+    assert (manager._runtime[1].status, manager._runtime[1].preset_id) == ("printing", 3)
+    assert manager._runtime[1].confirmed_generation != manager._runtime[1].generation
+
+    manager.handle_status(1, _state("RUNNING"))
+    await manager._runtime[1].send_task
+    assert len(sent) == 3
+    assert (manager._runtime[1].status, manager._runtime[1].preset_id) == ("printing", 3)
+    await manager.shutdown()
+    await client.aclose()
+
+
+@pytest.mark.asyncio
+async def test_manual_preset_restores_automatic_mapping_once_on_next_status():
+    sent = []
+
+    def handler(request: httpx.Request):
+        sent.append(request.content)
+        return httpx.Response(200, json={"success": True})
+
+    manager, client = _manager(handler)
+    manager.configure_printer(1, {"enabled": True, "base_url": "http://wled.local", "presets": {"printing": 3}})
+    manager.handle_status(1, _state("RUNNING"))
+    await manager._runtime[1].send_task
+    assert (manager._runtime[1].status, manager._runtime[1].preset_id) == ("printing", 3)
+
+    assert await manager.test_preset(1, "http://wled.local", 11) is True
+    manager.handle_status(1, _state("RUNNING"))
+    await manager._runtime[1].send_task
+    manager.handle_status(1, _state("RUNNING"))
+    await asyncio.sleep(0)
+    assert sent == [b'{"ps":3}', b'{"ps":11}', b'{"ps":3}']
+    assert (manager._runtime[1].status, manager._runtime[1].preset_id) == ("printing", 3)
+    await manager.shutdown()
+    await client.aclose()
+
+
+@pytest.mark.asyncio
+async def test_failed_manual_preset_keeps_automatic_mapping_retryable():
+    sent = []
+
+    def handler(request: httpx.Request):
+        sent.append(request.content)
+        return httpx.Response(503 if request.content == b'{"ps":11}' else 200, json={"success": True})
+
+    manager, client = _manager(handler)
+    manager.configure_printer(1, {"enabled": True, "base_url": "http://wled.local", "presets": {"printing": 3}})
+    manager.handle_status(1, _state("RUNNING"))
+    await manager._runtime[1].send_task
+
+    assert await manager.test_preset(1, "http://wled.local", 11) is False
+    manager.handle_status(1, _state("RUNNING"))
+    await manager._runtime[1].send_task
+    assert sent == [b'{"ps":3}', b'{"ps":11}', b'{"ps":3}']
+    assert (manager._runtime[1].status, manager._runtime[1].preset_id) == ("printing", 3)
+    await manager.shutdown()
+    await client.aclose()
+
+
+@pytest.mark.asyncio
+async def test_manual_preset_invalidates_slow_automatic_send(monkeypatch):
+    started = asyncio.Event()
+    release = asyncio.Event()
+    sent = []
+
+    async def delayed_send(printer_id, base_url, preset_id, *, status=None, quiet=False):
+        sent.append(preset_id)
+        if preset_id == 3 and sent.count(3) == 1:
+            started.set()
+            try:
+                await release.wait()
+            except asyncio.CancelledError:
+                await release.wait()
+        return True
+
+    manager = WLEDManager()
+    monkeypatch.setattr(manager, "send_preset", delayed_send)
+    manager.configure_printer(1, {"enabled": True, "base_url": "http://wled.local", "presets": {"printing": 3}})
+    manager.handle_status(1, _state("RUNNING"))
+    old_task = manager._runtime[1].send_task
+    await asyncio.wait_for(started.wait(), 1)
+
+    assert await manager.test_preset(1, "http://wled.local", 11) is True
+    release.set()
+    await old_task
+    assert manager._runtime[1].status is None
+    manager.handle_status(1, _state("RUNNING"))
+    await manager._runtime[1].send_task
+    assert sent == [3, 11, 3]
+    assert manager._runtime[1].status == "printing"
+    await manager.shutdown()
+
+
+@pytest.mark.asyncio
+async def test_status_change_during_manual_send_restores_new_mapping(monkeypatch):
+    started = asyncio.Event()
+    release = asyncio.Event()
+    sent = []
+
+    async def delayed_send(printer_id, base_url, preset_id, *, status=None, quiet=False):
+        sent.append(preset_id)
+        if preset_id == 11:
+            started.set()
+            await release.wait()
+        return True
+
+    manager = WLEDManager()
+    monkeypatch.setattr(manager, "send_preset", delayed_send)
+    manager.configure_printer(
+        1,
+        {"enabled": True, "base_url": "http://wled.local", "presets": {"printing": 3, "paused": 7}},
+    )
+    manager.handle_status(1, _state("RUNNING"))
+    await manager._runtime[1].send_task
+
+    manual_task = asyncio.create_task(manager.test_preset(1, "http://wled.local", 11))
+    await asyncio.wait_for(started.wait(), 1)
+    manager.handle_status(1, _state("PAUSE"))
+    await manager._runtime[1].send_task
+    assert manager._runtime[1].status == "paused"
+
+    release.set()
+    assert await manual_task is True
+    manager.handle_status(1, _state("PAUSE"))
+    await manager._runtime[1].send_task
+    assert sent == [3, 11, 7, 7]
+    assert (manager._runtime[1].status, manager._runtime[1].preset_id) == ("paused", 7)
+    await manager.shutdown()
+
+
+@pytest.mark.asyncio
+async def test_offline_lifecycle_send_and_reconnect_restore_mapping():
+    sent = []
+
+    def handler(request: httpx.Request):
+        sent.append(request.content)
+        return httpx.Response(200, json={"success": True})
+
+    manager, client = _manager(handler)
+    manager.configure_printer(
+        1, {"enabled": True, "base_url": "http://wled.local", "presets": {"printing": 3, "offline": 9}}
+    )
+    manager.handle_status(1, _state("RUNNING"))
+    await manager._runtime[1].send_task
+    manager.handle_offline(1)
+    await manager._runtime[1].send_task
+    assert (manager._runtime[1].status, manager._runtime[1].preset_id) == ("offline", 9)
+    manager.handle_status(1, _state("RUNNING"))
+    await manager._runtime[1].send_task
+    manager.handle_status(1, _state("RUNNING"))
+    await asyncio.sleep(0)
+    assert sent == [b'{"ps":3}', b'{"ps":9}', b'{"ps":3}']
+    await manager.shutdown()
+    await client.aclose()
+
+
+@pytest.mark.asyncio
+async def test_offline_lifecycle_skips_disabled_or_missing_preset():
+    sent = []
+
+    def handler(request: httpx.Request):
+        sent.append(request.content)
+        return httpx.Response(200, json={"success": True})
+
+    manager, client = _manager(handler)
+    manager.handle_offline(1)
+    manager.configure_printer(1, {"enabled": False, "base_url": "http://wled.local", "presets": {"offline": 9}})
+    manager.handle_offline(1)
+    manager.configure_printer(1, {"enabled": True, "base_url": "http://wled.local", "presets": {"printing": 3}})
+    manager.handle_offline(1)
+    await asyncio.sleep(0)
+    assert sent == []
+    await manager.shutdown()
+    await client.aclose()
+
+
+@pytest.mark.asyncio
+async def test_offline_lifecycle_http_failure_does_not_block_and_is_retryable(caplog):
+    manager, client = _manager(lambda request: httpx.Response(503, json={"error": "offline"}))
+    manager.retry_seconds = 0
+    manager.configure_printer(1, {"enabled": True, "base_url": "http://wled.local", "presets": {"offline": 9}})
+    manager.handle_offline(1)
+    await manager._runtime[1].send_task
+    assert manager._runtime[1].confirmed_generation is None
+    assert "state=offline -> preset=9 failed (HTTP 503)" in caplog.text
+    manager.handle_offline(1)
+    await manager._runtime[1].send_task
+    await manager.shutdown()
+    await client.aclose()
+
+
+@pytest.mark.asyncio
+async def test_late_automatic_send_cannot_confirm_over_offline(monkeypatch):
+    started = asyncio.Event()
+    release = asyncio.Event()
+    sent = []
+
+    async def delayed_send(printer_id, base_url, preset_id, *, status=None, quiet=False):
+        sent.append(preset_id)
+        if preset_id == 3:
+            started.set()
+            try:
+                await release.wait()
+            except asyncio.CancelledError:
+                await release.wait()
+        return True
+
+    manager = WLEDManager()
+    monkeypatch.setattr(manager, "send_preset", delayed_send)
+    manager.configure_printer(
+        1, {"enabled": True, "base_url": "http://wled.local", "presets": {"printing": 3, "offline": 9}}
+    )
+    manager.handle_status(1, _state("RUNNING"))
+    old_task = manager._runtime[1].send_task
+    await asyncio.wait_for(started.wait(), 1)
+    manager.handle_offline(1)
+    await manager._runtime[1].send_task
+    release.set()
+    await old_task
+    assert sent == [3, 9]
+    assert (manager._runtime[1].status, manager._runtime[1].preset_id) == ("offline", 9)
+    await manager.shutdown()
+
+
+@pytest.mark.asyncio
+async def test_late_old_send_cannot_confirm_over_new_state(monkeypatch):
+    started = asyncio.Event()
+    release = asyncio.Event()
+
+    async def delayed_send(printer_id, base_url, preset_id, *, status=None, quiet=False):
+        if preset_id == 3:
+            started.set()
+            try:
+                await release.wait()
+            except asyncio.CancelledError:
+                await release.wait()
+        return True
+
+    manager = WLEDManager()
+    monkeypatch.setattr(manager, "send_preset", delayed_send)
+    manager.configure_printer(
+        1,
+        {"enabled": True, "base_url": "http://wled.local", "presets": {"printing": 3, "paused": 7}},
+    )
+    manager.handle_status(1, _state("RUNNING"))
+    old_task = manager._runtime[1].send_task
+    await asyncio.wait_for(started.wait(), 1)
+
+    manager.handle_status(1, _state("PAUSE"))
+    assert old_task.cancelled() or old_task.cancelling()
+    await manager._runtime[1].send_task
+    assert (manager._runtime[1].status, manager._runtime[1].preset_id) == ("paused", 7)
+
+    release.set()
+    await old_task
+    assert (manager._runtime[1].status, manager._runtime[1].preset_id) == ("paused", 7)
+    await manager.shutdown()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("raw_state", "stage", "hms", "gate", "required", "presets", "expected_state", "expected_preset"),
+    [
+        ("PAUSE", 6, False, False, True, {"paused": 7}, "paused", 7),
+        ("PAUSE", 6, True, False, True, {"hms_error": 8, "paused": 7}, "hms_error", 8),
+        ("PAUSE", 16, False, False, True, {"filament_problem": 6, "paused": 7}, "paused", 7),
+        ("RUNNING", -1, True, False, True, {"printing": 3}, "printing", 3),
+        ("FINISH", -1, False, True, False, {"queue_waiting": 5, "finished": 9}, "finished", 9),
+        ("FINISH", -1, False, True, True, {"queue_waiting": 5, "finished": 9}, "queue_waiting", 5),
+        ("FINISH", -1, False, True, True, {"finished": 9}, "finished", 9),
+        ("FAILED", -1, False, True, True, {"error": 10}, "error", 10),
+        ("IDLE", -1, False, True, True, {"idle": 1}, "idle", 1),
+        ("FINISH", -1, False, True, True, {"idle": 1}, None, None),
+        ("RUNNING", -1, False, False, True, {"idle": 1}, None, None),
+    ],
+)
+async def test_missing_preset_falls_back_only_to_applicable_states(
+    raw_state, stage, hms, gate, required, presets, expected_state, expected_preset
+):
+    sent = []
+
+    def handler(request: httpx.Request):
+        sent.append(request)
+        return httpx.Response(200, json={"success": True})
+
+    manager, client = _manager(handler)
+    manager.require_plate_clear = required
+    manager.configure_printer(1, {"enabled": True, "base_url": "http://wled.local", "presets": presets})
+    fault = HMSError(code="fault", attr=0, module=0, severity=1, description="Nozzle fault")
+    manager.handle_status(
+        1,
+        _state(raw_state, stg_cur=stage, hms_errors=[fault] if hms else []),
+        awaiting_plate_clear=gate,
+    )
+    await asyncio.sleep(0)
+
+    assert [request.content for request in sent] == (
+        [f'{{"ps":{expected_preset}}}'.encode()] if expected_preset else []
+    )
+    assert manager._runtime[1].status == expected_state
+    await manager.shutdown()
+    await client.aclose()
+
+
+def test_invalid_persisted_config_is_treated_as_disabled():
+    manager = WLEDManager()
+    manager.configure_printer(1, {"enabled": True, "base_url": None})
+    assert manager._configs[1].enabled is False
+
+    manager.configure_printer(
+        1,
+        {"enabled": True, "base_url": "http://169.254.169.254/latest/meta-data", "presets": {"printing": 3}},
+    )
+    manager.handle_status(1, _state("RUNNING"))
+    assert manager._configs[1].enabled is False
+    assert manager._runtime == {}
+    assert manager._client is None
+
+
+@pytest.mark.asyncio
+async def test_finished_timeout_confirms_idle_until_new_print_cycle(caplog):
+    caplog.set_level(logging.INFO, logger="backend.app.services.wled")
+    sent = []
+
+    def handler(request: httpx.Request):
+        sent.append(request)
+        return httpx.Response(200, json={"success": True})
+
+    manager, client = _manager(handler)
+    manager.configure_printer(
+        1,
+        {
+            "enabled": True,
+            "base_url": "http://wled.local",
+            "presets": {"finished": 9, "idle": 1, "printing": 3},
+            "finished_timeout_seconds": 10,
+        },
+    )
+    manager.handle_status(1, _state("FINISH"))
+    await manager._runtime[1].send_task
+    runtime = manager._runtime[1]
+    await manager._finish_timeout(1, runtime.generation, "http://wled.local", 1, 0)
+    assert [request.content for request in sent] == [b'{"ps":9}', b'{"ps":1}']
+    assert "[WLED] Printer 1 state=idle -> preset=1" in caplog.messages
+    assert (runtime.status, runtime.preset_id) == ("idle", 1)
+
+    manager.handle_status(1, _state("FINISH", progress=100))
+    await asyncio.sleep(0)
+    assert len(sent) == 2
+
+    manager.handle_status(1, _state("IDLE"))
+    await manager._runtime[1].send_task
+    manager.handle_status(1, _state("FINISH"))
+    await asyncio.sleep(0)
+    assert [request.content for request in sent].count(b'{"ps":9}') == 1
+
+    manager.handle_status(1, _state("RUNNING"))
+    await manager._runtime[1].send_task
+    assert sent[-1].content == b'{"ps":3}'
+    manager.handle_status(1, _state("FINISH"))
+    await manager._runtime[1].send_task
+    assert [request.content for request in sent].count(b'{"ps":9}') == 2
+    await manager.shutdown()
+    await client.aclose()
+
+
+@pytest.mark.asyncio
+async def test_finished_timeout_cancels_when_printing_resumes():
+    manager, client = _manager(lambda request: httpx.Response(200, json={"success": True}))
+    manager.configure_printer(
+        1,
+        {
+            "enabled": True,
+            "base_url": "http://wled.local",
+            "presets": {"finished": 9, "idle": 1, "printing": 3},
+            "finished_timeout_seconds": 10,
+        },
+    )
+    manager.handle_status(1, _state("FINISH"))
+    timer = manager._runtime[1].finished_task
+    manager.handle_status(1, _state("RUNNING"))
+    assert timer is not None and timer.cancelling()
+    await manager._runtime[1].send_task
+    await manager.shutdown()
+    await client.aclose()
+
+
+@pytest.mark.asyncio
+async def test_failed_finished_timeout_idle_send_is_retryable():
+    sent = []
+
+    def handler(request: httpx.Request):
+        sent.append(request)
+        return httpx.Response(503 if request.content == b'{"ps":1}' and len(sent) == 2 else 200, json={"success": True})
+
+    manager, client = _manager(handler)
+    manager.retry_seconds = 0
+    manager.configure_printer(
+        1,
+        {
+            "enabled": True,
+            "base_url": "http://wled.local",
+            "presets": {"finished": 9, "idle": 1},
+            "finished_timeout_seconds": 10,
+        },
+    )
+    manager.handle_status(1, _state("FINISH"))
+    runtime = manager._runtime[1]
+    await runtime.send_task
+    runtime.finished_task.cancel()
+    runtime.finished_task = asyncio.create_task(
+        manager._finish_timeout(1, runtime.generation, "http://wled.local", 1, 0)
+    )
+    await runtime.finished_task
+    assert (runtime.status, runtime.preset_id) == ("finished", 9)
+
+    manager.handle_status(1, _state("FINISH"))
+    await runtime.finished_task
+    assert [request.content for request in sent] == [b'{"ps":9}', b'{"ps":1}', b'{"ps":1}']
+    assert (runtime.status, runtime.preset_id) == ("idle", 1)
+    await manager.shutdown()
+    await client.aclose()
+
+
+@pytest.mark.asyncio
+async def test_late_finished_timeout_idle_cannot_confirm_over_new_printing(monkeypatch):
+    started = asyncio.Event()
+    release = asyncio.Event()
+
+    async def delayed_send(printer_id, base_url, preset_id, *, status=None, quiet=False):
+        if preset_id == 1:
+            started.set()
+            try:
+                await release.wait()
+            except asyncio.CancelledError:
+                await release.wait()
+        return True
+
+    manager = WLEDManager()
+    monkeypatch.setattr(manager, "send_preset", delayed_send)
+    manager.configure_printer(
+        1,
+        {
+            "enabled": True,
+            "base_url": "http://wled.local",
+            "presets": {"finished": 9, "idle": 1, "printing": 3},
+            "finished_timeout_seconds": 10,
+        },
+    )
+    manager.handle_status(1, _state("FINISH"))
+    runtime = manager._runtime[1]
+    await runtime.send_task
+    runtime.finished_task.cancel()
+    old_timeout = asyncio.create_task(manager._finish_timeout(1, runtime.generation, "http://wled.local", 1, 0))
+    runtime.finished_task = old_timeout
+    await asyncio.wait_for(started.wait(), 1)
+
+    manager.handle_status(1, _state("RUNNING"))
+    await runtime.send_task
+    assert (runtime.status, runtime.preset_id) == ("printing", 3)
+    release.set()
+    await old_timeout
+    assert (runtime.status, runtime.preset_id) == ("printing", 3)
+    await manager.shutdown()

+ 121 - 0
backend/tests/unit/test_a2l_mixed_ams_3499.py

@@ -0,0 +1,121 @@
+"""An A2L with an AMS 2 Pro and an AMS Lite shows both on the card (#3499).
+
+The firmware sends unit ids as strings ("0"); the AMS Lite's physical id 16 is
+normalised to the int 6. With both units attached the merge sorted "0" against
+6 and raised, so the unit list was never stored. The frame's own AMS object
+stayed in its place, and every later frame (and the AMS labels API) failed on
+its string keys: the card showed only the external spool until one AMS was
+unplugged and the printer reconnected.
+
+Frame values follow the reporter's support package: AMS 2 Pro with four bare
+slots, AMS Lite with four Bambu PLA Basic spools, ams_exist_bits "1001",
+tray_exist_bits "f000000".
+"""
+
+from unittest.mock import patch
+
+from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+
+def _client() -> BambuMQTTClient:
+    return BambuMQTTClient(ip_address="10.0.0.1", serial_number="A2L", access_code="c", model="A2L")
+
+
+def _lite_tray(slot: int, colour: str) -> dict:
+    return {
+        "id": str(slot),
+        "state": 3,
+        "remain": -1,
+        "tray_type": "PLA",
+        "tray_sub_brands": "PLA Basic",
+        "tray_info_idx": "GFA00",
+        "tray_color": colour,
+    }
+
+
+def _frame(lite_id=16) -> dict:
+    return {
+        "ams": {
+            "ams": [
+                {"id": "0", "info": "2003", "tray": [{"id": str(n)} for n in range(4)]},
+                {
+                    "id": lite_id,
+                    "info": "30001005",
+                    "tray": [
+                        _lite_tray(0, "000000FF"),
+                        _lite_tray(1, "3F8E43FF"),
+                        _lite_tray(2, "0056B8FF"),
+                        _lite_tray(3, "FFFFFFFF"),
+                    ],
+                },
+            ],
+            "ams_exist_bits": "1001",
+            "tray_exist_bits": "f000000",
+            "tray_now": "255",
+            "power_on_flag": True,
+        },
+        "gcode_state": "IDLE",
+    }
+
+
+def _units(client: BambuMQTTClient) -> list:
+    units = client.state.raw_data.get("ams")
+    assert isinstance(units, list), f"AMS units are {type(units).__name__}, not a list"
+    return units
+
+
+def test_both_units_are_stored():
+    client = _client()
+
+    client._process_message({"print": _frame()})
+
+    units = _units(client)
+    assert [str(u["id"]) for u in units] == ["0", "6"]
+    assert [t["tray_color"] for t in units[1]["tray"]] == ["000000FF", "3F8E43FF", "0056B8FF", "FFFFFFFF"]
+
+
+def test_a_string_lite_id_is_stored_too():
+    client = _client()
+
+    client._process_message({"print": _frame(lite_id="16")})
+
+    assert [str(u["id"]) for u in _units(client)] == ["0", "6"]
+
+
+def test_later_frames_keep_both_units():
+    client = _client()
+    for _ in range(3):
+        client._process_message({"print": _frame()})
+
+    assert [str(u["id"]) for u in _units(client)] == ["0", "6"]
+
+
+def test_a_frame_the_ams_handling_fails_on_leaves_no_ams_object_behind():
+    client = _client()
+
+    # Any failure in the AMS handling, as the mixed-id sort was.
+    with patch.object(client, "_handle_ams_data", side_effect=TypeError("boom")):
+        client._process_message({"print": _frame()})
+
+    assert "ams" not in client.state.raw_data
+
+
+def test_a_stored_ams_object_does_not_block_later_frames():
+    # An install already in the broken state: the AMS object in place of the list.
+    client = _client()
+    client.state.raw_data["ams"] = {"ams": [], "ams_exist_bits": "1001"}
+
+    client._process_message({"print": _frame()})
+
+    assert [str(u["id"]) for u in _units(client)] == ["0", "6"]
+
+
+def test_units_are_in_numeric_order():
+    client = _client()
+    frame = _frame()
+    frame["ams"]["ams"].append({"id": "1", "tray": [{"id": str(n)} for n in range(4)]})
+    frame["ams"]["ams_exist_bits"] = "1003"
+
+    client._process_message({"print": frame})
+
+    assert [str(u["id"]) for u in _units(client)] == ["0", "1", "6"]

+ 233 - 0
backend/tests/unit/test_auto_drying_parked_3478.py

@@ -0,0 +1,233 @@
+"""Auto-drying and a countdown the firmware never runs (#3478).
+
+An AMS HT took a drying command mid-print and left the heater off: dry_time
+sat at 720 with dry_status Off for a week. The MQTT layer flags that as
+dry_countdown_stalled, but auto-drying treated any dry_time > 0 as a running
+cycle, so the unit was never dried again. Now a parked timer is not drying: a
+cycle Bambuddy armed that parks is closed out and counted on its own (it says
+nothing about humidity), and two in a row suspend the unit with a message that
+says the printer did not heat.
+"""
+
+import time
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services.print_scheduler import (
+    AUTO_DRY_MAX_NEVER_HEATED_CYCLES,
+    AUTO_DRY_REARM_COOLDOWN_SECONDS,
+    PrintScheduler,
+)
+
+UNIT = (1, 128)
+WET = 75
+DRY = 50
+
+
+def _state(dry_time: int, stalled: bool, humidity: int = WET):
+    state = MagicMock()
+    state.raw_data = {
+        "ams": [
+            {
+                "id": 128,
+                "module_type": "n3s",
+                "dry_time": dry_time,
+                "dry_status": 0,
+                "dry_countdown_stalled": stalled,
+                "humidity_raw": str(humidity),
+                "dry_sf_reason": [],
+                "tray": [{"tray_type": "PLA"}],
+            }
+        ]
+    }
+    state.firmware_version = "01.09.00.00"
+    return state
+
+
+def _setting(value):
+    s = MagicMock()
+    s.value = value
+    return s
+
+
+def _db():
+    settings = {
+        "queue_drying_enabled": _setting("false"),
+        "ambient_drying_enabled": _setting("true"),
+        "ams_humidity_fair": _setting("60"),
+        "queue_drying_block": _setting("false"),
+    }
+
+    async def execute(stmt):
+        result = MagicMock()
+        try:
+            params = list(stmt.compile().params.values())
+        except Exception:
+            params = []
+        for key, val in settings.items():
+            if key in params:
+                result.scalar_one_or_none.return_value = val
+                return result
+        if "printer" in str(stmt).lower():
+            printer = MagicMock()
+            printer.id = 1
+            printer.is_active = True
+            scalars = MagicMock()
+            scalars.__iter__ = MagicMock(return_value=iter([printer]))
+            result.scalars.return_value = scalars
+        else:
+            result.scalar_one_or_none.return_value = None
+        return result
+
+    db = AsyncMock()
+    db.execute = AsyncMock(side_effect=execute)
+    return db
+
+
+@pytest.fixture
+def env():
+    with (
+        patch("backend.app.services.print_scheduler.printer_manager") as pm,
+        patch("backend.app.services.print_scheduler.notification_service") as notify,
+        patch("backend.app.services.print_scheduler.supports_drying", return_value=True),
+    ):
+        pm.is_connected.return_value = True
+        pm.get_model.return_value = "P2S"
+        pm.send_drying_command.return_value = True
+        notify.on_ams_drying_suspended = AsyncMock()
+        scheduler = PrintScheduler()
+        scheduler._is_printer_idle = MagicMock(return_value=True)
+        db = _db()
+
+        async def run(dry_time, stalled, humidity=WET):
+            pm.get_status.return_value = _state(dry_time, stalled, humidity)
+            await scheduler._check_auto_drying(db, [], set())
+
+        yield scheduler, pm, notify, run
+
+
+def _age_out_cooldown(scheduler):
+    scheduler._auto_dry_units[UNIT]["ended_at"] -= AUTO_DRY_REARM_COOLDOWN_SECONDS + 1
+
+
+async def _parked_cycle(run):
+    """Arm, see the command taken, then see the stall detector flag it."""
+    await run(0, False)
+    await run(720, False)
+    await run(720, True)
+
+
+class TestParkedTimerIsNotDrying:
+    @pytest.mark.asyncio
+    async def test_a_parked_timer_bambuddy_did_not_start_gets_a_start(self, env):
+        """The reporter's unit: parked at 720, humidity above the threshold."""
+        scheduler, pm, _notify, run = env
+        await run(720, True)
+        pm.send_drying_command.assert_called_once_with(1, 128, 45, 12, mode=1, filament="PLA")
+        assert scheduler._auto_dry_units[UNIT]["running"] is True
+
+    @pytest.mark.asyncio
+    async def test_a_timer_not_yet_called_parked_still_runs(self, env):
+        _scheduler, pm, _notify, run = env
+        await run(720, False)
+        pm.send_drying_command.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_a_parked_cycle_is_closed_counted_apart_and_cools_off(self, env):
+        scheduler, pm, _notify, run = env
+        await _parked_cycle(run)
+
+        unit = scheduler._auto_dry_units[UNIT]
+        assert "running" not in unit
+        assert unit["never_heated"] == 1
+        assert unit.get("unproductive", 0) == 0
+        assert "best_end_humidity" not in unit
+        assert isinstance(unit["ended_at"], float)
+
+        await run(720, True)
+        assert pm.send_drying_command.call_count == 1
+
+    @pytest.mark.asyncio
+    async def test_parked_cycles_suspend_with_the_not_heating_message(self, env):
+        scheduler, pm, notify, run = env
+        for _ in range(AUTO_DRY_MAX_NEVER_HEATED_CYCLES - 1):
+            await _parked_cycle(run)
+            _age_out_cooldown(scheduler)
+        # The next start goes out on top of the parked timer and parks again.
+        await run(720, True)
+        await run(720, True)
+
+        assert pm.send_drying_command.call_count == AUTO_DRY_MAX_NEVER_HEATED_CYCLES
+        unit = scheduler._auto_dry_units[UNIT]
+        assert unit["suspended"] is True
+        notify.on_ams_drying_suspended.assert_awaited_once()
+        assert notify.on_ams_drying_suspended.await_args.kwargs["never_heated"] is True
+
+        _age_out_cooldown(scheduler)
+        await run(720, True)
+        assert pm.send_drying_command.call_count == AUTO_DRY_MAX_NEVER_HEATED_CYCLES
+
+    @pytest.mark.asyncio
+    async def test_a_cycle_that_runs_resets_the_parked_count(self, env):
+        scheduler, pm, notify, run = env
+        await _parked_cycle(run)
+        _age_out_cooldown(scheduler)
+        # This one counts down and finishes.
+        await run(720, True)
+        await run(719, False)
+        await run(0, False)
+
+        assert "never_heated" not in scheduler._auto_dry_units[UNIT]
+        notify.on_ams_drying_suspended.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_dry_humidity_lifts_a_not_heating_suspension(self, env):
+        scheduler, _pm, _notify, run = env
+        scheduler._auto_dry_units[UNIT] = {"suspended": True, "never_heated": 2, "ended_at": None}
+        await run(0, False, humidity=DRY)
+        unit = scheduler._auto_dry_units[UNIT]
+        assert not unit.get("suspended")
+        assert "never_heated" not in unit
+
+
+class TestSyncDryingState:
+    def test_a_printer_with_only_a_parked_timer_is_not_drying(self):
+        scheduler = PrintScheduler()
+        scheduler._drying_in_progress[1] = time.monotonic()
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = _state(720, True)
+            scheduler._sync_drying_state()
+        assert 1 not in scheduler._drying_in_progress
+
+    def test_a_running_timer_keeps_the_printer_drying(self):
+        scheduler = PrintScheduler()
+        scheduler._drying_in_progress[1] = time.monotonic()
+        with patch("backend.app.services.print_scheduler.printer_manager") as pm:
+            pm.get_status.return_value = _state(700, False)
+            scheduler._sync_drying_state()
+        assert 1 in scheduler._drying_in_progress
+
+
+class TestNotification:
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        ("never_heated", "template"),
+        [(False, "ams_drying_suspended"), (True, "ams_drying_not_heating")],
+    )
+    async def test_picks_the_template_on_the_same_switch(self, never_heated, template):
+        from backend.app.services.notification_service import NotificationService
+
+        service = NotificationService()
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock),
+            patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as build,
+        ):
+            get.return_value = [MagicMock()]
+            build.return_value = ("t", "m")
+            await service.on_ams_drying_suspended(
+                1, "P2S", "HT-A", 75.0, 60.0, 2, AsyncMock(), never_heated=never_heated
+            )
+        assert get.await_args.args[1] == "on_ams_drying_suspended"
+        assert build.await_args.args[1] == template

+ 229 - 0
backend/tests/unit/test_cancelled_sqlite_close_3515.py

@@ -0,0 +1,229 @@
+"""A read cancelled by a leaving viewer must not hang its connection (#3515).
+
+A camera viewer leaving cancels its stream with AnyIO's level cancellation. A
+read cancelled halfway makes SQLAlchemy discard the connection; when that close
+is cancelled too, it calls aiosqlite's ``stop()`` while the close still runs.
+aiosqlite 0.22.1 then never finished the close (omnilib/aiosqlite#403), the
+session's exit waited on it forever, and the cancelled wait spun a CPU core.
+"""
+
+import asyncio
+import os
+import subprocess
+import sys
+import textwrap
+import threading
+from contextlib import asynccontextmanager
+from pathlib import Path
+from unittest.mock import AsyncMock, patch
+
+import aiosqlite
+import anyio
+import pytest
+
+from backend.app.core import auth, database  # noqa: F401 - database applies the aiosqlite fix
+from backend.app.services import camera_light
+
+pytestmark = pytest.mark.unit
+
+
+class TestAiosqliteStop:
+    async def test_a_stop_during_a_close_lets_the_close_finish(self):
+        # omnilib/aiosqlite#403's reproduction: the worker exits at the stop
+        # requested from outside, before the close's own stop is served.
+        gate = threading.Event()
+        conn = await aiosqlite.connect(":memory:")
+        await conn.create_function("hold", 0, lambda: gate.wait() and 1)
+        busy = asyncio.create_task(conn.execute("SELECT hold()"))
+        await asyncio.sleep(0.05)
+
+        closing = asyncio.create_task(conn.close())
+        await asyncio.sleep(0.05)
+        conn.stop()
+        gate.set()
+        await busy
+
+        await asyncio.wait_for(closing, timeout=5)
+
+    async def test_a_second_stop_returns_the_first_ones_future(self):
+        conn = await aiosqlite.connect(":memory:")
+        first = conn.stop()
+
+        assert conn.stop() is first
+        await asyncio.wait_for(first, timeout=5)
+
+
+# Many short reads, cancelled at once by an AnyIO task group as a leaving viewer
+# cancels its stream, against the engine Bambuddy creates. Run in a process of
+# its own: a hung close can't be cancelled, so the test would hang with it.
+_BURST = textwrap.dedent(
+    """
+    import asyncio, sys
+
+    import anyio
+    from sqlalchemy import text
+    from sqlalchemy.ext.asyncio import async_sessionmaker
+
+    from backend.app.core.database import _create_engine
+
+
+    async def main(url):
+        engine = _create_engine(url)
+        sessions = async_sessionmaker(engine, expire_on_commit=False)
+        async with engine.begin() as conn:
+            await conn.execute(text("create table settings (key text primary key, value text)"))
+            await conn.execute(text("insert into settings values ('camera_light_mode', 'off')"))
+
+        async def read():
+            try:
+                async with sessions() as session:
+                    await session.execute(text("select value from settings"))
+                    await session.execute(text("select value from settings"))
+            except Exception:
+                pass
+
+        for i in range(40):
+            async with anyio.create_task_group() as group:
+                for _ in range(8):
+                    group.start_soon(read)
+                await asyncio.sleep((i % 10) * 0.0002)
+                group.cancel_scope.cancel()
+        await engine.dispose()
+        await asyncio.sleep(1)
+        left = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
+        print("pending", len(left))
+
+
+    anyio.run(main, sys.argv[1], backend="asyncio")
+    """
+)
+
+
+def test_cancelled_reads_leave_no_connection_behind(tmp_path):
+    url = f"sqlite+aiosqlite:///{tmp_path / 'burst.db'}"
+    # Its own DATABASE_URL: never the repo's .env.
+    env = {**os.environ, "DATABASE_URL": url}
+    try:
+        result = subprocess.run(
+            [sys.executable, "-c", _BURST, url],
+            cwd=Path(__file__).resolve().parents[3],
+            env=env,
+            capture_output=True,
+            text=True,
+            timeout=60,
+        )
+    except subprocess.TimeoutExpired:
+        pytest.fail("a cancelled read left its connection's close hanging")
+
+    assert result.returncode == 0, result.stderr[-2000:]
+    assert result.stdout.strip().splitlines()[-1] == "pending 0"
+
+
+class _Session:
+    """A session whose every step yields to the loop, so a cancel lands."""
+
+    def __init__(self):
+        self.closed = False
+
+    async def __aenter__(self):
+        await asyncio.sleep(0)
+        return self
+
+    async def __aexit__(self, *exc):
+        await asyncio.sleep(0)
+        self.closed = True
+
+
+class TestTheStreamReadsRunToTheEnd:
+    """Both run inside a camera stream; its viewer leaving must not cut them off."""
+
+    async def test_the_camera_light_settings(self):
+        session = _Session()
+        values = {"camera_light_mode": "all", "camera_light_delay": "3"}
+
+        async def get_setting(_db, key):
+            await asyncio.sleep(0)
+            return values[key]
+
+        result = None
+        with (
+            patch.object(database, "async_session", lambda: session),
+            patch("backend.app.api.routes.settings.get_setting", get_setting),
+            anyio.CancelScope() as viewer_left,
+        ):
+            viewer_left.cancel()
+            result = await camera_light._read_settings()
+
+        assert result == ("all", 3.0)
+        assert session.closed
+
+    async def test_the_stream_re_check(self):
+        session = _Session()
+
+        @asynccontextmanager
+        async def factory():
+            async with session:
+                yield session
+
+        result = None
+        with (
+            patch.object(auth, "async_session", factory),
+            patch.object(auth, "is_auth_enabled", AsyncMock(return_value=False)),
+            anyio.CancelScope() as viewer_left,
+        ):
+            viewer_left.cancel()
+            result = await auth.stream_opener_still_allowed(auth.StreamOpener(auth_on=False), printer_id=1)
+
+        assert result is True
+        assert session.closed
+
+
+class TestOlderAiosqlite:
+    def test_a_version_without_stop_is_left_alone(self, monkeypatch):
+        # Before 0.22.1 aiosqlite has no stop(), and SQLAlchemy calls none. The
+        # in-app update keeps such a version, so it must not fail the import.
+        class Connection:
+            pass
+
+        monkeypatch.setattr("aiosqlite.core.Connection", Connection)
+
+        database._make_aiosqlite_stop_idempotent()
+
+        assert not hasattr(Connection, "stop")
+
+
+class TestALeavingViewerDuringASlowReCheck:
+    async def test_the_retry_does_not_keep_the_stream(self, monkeypatch):
+        # Every database connection busy: the shielded re-check waits for one
+        # and times out. A viewer who left meanwhile must not be held through
+        # the immediate retry's own shielded wait as well.
+        from backend.app.api.routes import camera
+
+        monkeypatch.setattr(camera, "STREAM_RECHECK_SECONDS", 0.0)
+        monkeypatch.setattr(camera, "STREAM_RECHECK_RETRY_SECONDS", 0.0)
+        calls = 0
+
+        async def slow_failure(_opener, _printer_id):
+            nonlocal calls
+            calls += 1
+            with anyio.CancelScope(shield=True):
+                await asyncio.sleep(0.2)
+            raise TimeoutError("pool")
+
+        monkeypatch.setattr(camera, "stream_opener_still_allowed", slow_failure)
+
+        async def frames():
+            while True:
+                yield b"frame"
+                await asyncio.sleep(0)
+
+        async def watch():
+            async for _ in camera._while_allowed(frames(), auth.StreamOpener(auth_on=False), 1):
+                pass
+
+        async with anyio.create_task_group() as group:
+            group.start_soon(watch)
+            await asyncio.sleep(0.05)
+            group.cancel_scope.cancel()
+
+        assert calls == 1

+ 57 - 1
backend/tests/unit/test_completion_queue_item_match.py

@@ -14,10 +14,15 @@ the unverifiable cases through rather than strand an item in ``printing``.
 
 import pytest
 
-from backend.app.main import _completion_belongs_to_queue_item, _subtask_name_from_filename
+from backend.app.main import (
+    _ask_outcome_for_external_print,
+    _completion_belongs_to_queue_item,
+    _subtask_name_from_filename,
+)
 from backend.app.models.archive import PrintArchive
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.printer import Printer
+from backend.app.models.settings import Settings
 
 
 class TestSubtaskNameFromFilename:
@@ -89,6 +94,37 @@ class TestCompletionBelongsToQueueItem:
 
         assert await _completion_belongs_to_queue_item(db_session, item, {"subtask_name": "ams_rack"}) is True
 
+    @pytest.mark.parametrize(
+        ("archive_filename", "subtask"),
+        [
+            # A1 mini after power-loss recovery: dispatched as the bare name,
+            # resumed and completed under the file name (#3485).
+            (
+                "Bottom-heavy; stands upright without saucer.3mf",
+                "Bottom-heavy;_stands_upright_without_saucer.3mf",
+            ),
+            ("mini-bench_plate_1.3mf", "mini-bench_plate_1.3mf"),
+            ("new bench.gcode.3mf", "new_bench.3mf"),
+            ("new bench.gcode.3mf", "new_bench.gcode.3mf"),
+        ],
+    )
+    async def test_accepts_its_own_print_echoed_with_the_extension(self, db_session, archive_filename, subtask):
+        item = await _seed(db_session, archive_filename=archive_filename)
+
+        assert await _completion_belongs_to_queue_item(db_session, item, {"subtask_name": subtask}) is True
+
+    async def test_a_model_named_like_a_file_still_matches(self, db_session):
+        # "Foo.3mf" is the model's own name here; stripping the echo to "Foo"
+        # must not be the only form tried.
+        item = await _seed(db_session, archive_filename="Foo.3mf.gcode.3mf")
+
+        assert await _completion_belongs_to_queue_item(db_session, item, {"subtask_name": "Foo.3mf"}) is True
+
+    async def test_an_extension_does_not_make_another_print_match(self, db_session):
+        item = await _seed(db_session, archive_filename="AMS_Rack.gcode.3mf")
+
+        assert await _completion_belongs_to_queue_item(db_session, item, {"subtask_name": "Test.3mf"}) is False
+
     @pytest.mark.parametrize("subtask", [None, "", "   "])
     async def test_lets_an_unidentified_completion_through(self, db_session, subtask):
         # No subtask name to compare means unverifiable, not wrong. Refusing here
@@ -119,6 +155,26 @@ class TestCompletionBelongsToQueueItem:
         assert await _completion_belongs_to_queue_item(db_session, item, {"subtask_name": "Anything"}) is True
 
 
+@pytest.mark.asyncio
+class TestOutcomePromptForAResumedQueuePrint:
+    """The outcome prompt holds a printing queue row against the observed name
+    with the same rule, so a resumed queue print must not read as external."""
+
+    async def test_a_resumed_queue_print_is_not_asked_about(self, db_session):
+        item = await _seed(db_session, archive_filename="mini-bench_plate_1.3mf")
+        db_session.add(Settings(key="confirm_outcome_external_prints", value="true"))
+        await db_session.flush()
+
+        assert await _ask_outcome_for_external_print(db_session, item.printer_id, "mini-bench_plate_1.3mf") is False
+
+    async def test_another_file_is_still_asked_about(self, db_session):
+        item = await _seed(db_session, archive_filename="mini-bench_plate_1.3mf")
+        db_session.add(Settings(key="confirm_outcome_external_prints", value="true"))
+        await db_session.flush()
+
+        assert await _ask_outcome_for_external_print(db_session, item.printer_id, "Other.3mf") is True
+
+
 @pytest.mark.sqlite_only  # on PostgreSQL the guard checks the throwaway server's databases instead
 class TestDisposableDatabaseGuard:
     """The suite must never be able to open a session against a real database.

+ 86 - 0
backend/tests/unit/test_external_slot_partial_push_3513.py

@@ -0,0 +1,86 @@
+"""A partial vt_tray push updates the external slot instead of replacing it (#3513).
+
+The A1 and A1 mini report a change to the external spool as a delta with only
+the changed keys, often without the slot id: a colour-only change pushes just
+``{"tray_color": ...}``. Stored as it came, it emptied the slot (no type, no
+preset) until the next full push, and without its id the assignment check
+could not find slot 254 and timed out on the old preset.
+"""
+
+from unittest.mock import MagicMock
+
+import pytest
+
+from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+pytestmark = pytest.mark.unit
+
+# The reporter's slot after a full push.
+FULL = {
+    "id": "254",
+    "tray_type": "PLA",
+    "tray_sub_brands": "",
+    "tray_color": "C0C0C0FF",
+    "tray_info_idx": "P0ea739d",
+    "nozzle_temp_min": "190",
+    "nozzle_temp_max": "230",
+    "k": 0.02,
+    "cali_idx": 5,
+    "remain": 0,
+}
+
+
+def _client(model: str = "A1 mini") -> BambuMQTTClient:
+    client = BambuMQTTClient(ip_address="10.0.0.1", serial_number="SN", access_code="c", model=model)
+    client._process_message({"print": {"command": "push_status", "vt_tray": dict(FULL)}})
+    return client
+
+
+def _push(client: BambuMQTTClient, vt_tray) -> dict:
+    client._process_message({"print": {"command": "push_status", "vt_tray": vt_tray}})
+    return client.state.raw_data["vt_tray"]
+
+
+class TestTheDelta:
+    def test_a_colour_only_change_keeps_the_rest_of_the_slot(self):
+        client = _client()
+
+        slots = _push(client, {"tray_color": "FFFFFFFF"})
+
+        assert slots == [{**FULL, "tray_color": "FFFFFFFF"}]
+        assert client._find_verify_tray(255, 0) is slots[0]
+
+    def test_the_same_id_merges_too(self):
+        slots = _push(_client(), {"id": "254", "tray_info_idx": "GFL99"})
+
+        assert slots == [{**FULL, "tray_info_idx": "GFL99"}]
+
+    def test_the_delta_confirms_a_pending_assignment(self):
+        client = _client()
+        seen = MagicMock()
+        client.on_assignment_verified = seen
+        client.register_assignment_verification(255, 0, "GFL03", "FFFFFFFF", None)
+
+        _push(client, {"tray_info_idx": "GFL03", "tray_color": "FFFFFFFF"})
+
+        seen.assert_called_once()
+        assert seen.call_args.args[:3] == (255, 0, True)
+
+
+class TestWholesale:
+    def test_an_emptied_slot_keeps_nothing_of_the_spool(self):
+        emptied = {"id": "254", "tray_type": "", "tray_color": "", "tray_info_idx": ""}
+
+        assert _push(_client(), emptied) == [emptied]
+
+    def test_another_slot_id_replaces_it(self):
+        other = {"id": "255", "tray_type": "PETG"}
+
+        assert _push(_client(), other) == [other]
+
+    def test_two_external_slots_ignore_a_single_dict(self):
+        client = BambuMQTTClient(ip_address="10.0.0.1", serial_number="SN", access_code="c", model="H2D")
+        both = [{**FULL, "id": "254"}, {**FULL, "id": "255", "tray_type": "PETG"}]
+        client._process_message({"print": {"command": "push_status", "vir_slot": [dict(s) for s in both]}})
+
+        assert _push(client, {"tray_color": "FFFFFFFF"}) == both

+ 134 - 0
backend/tests/unit/test_failed_print_bogus_remain_3511.py

@@ -0,0 +1,134 @@
+"""A print stopped before its first layer charges nothing, whatever the AMS reads (#3511).
+
+The reporter's X2D failed 290 s in. The 3MF named slot 2 on AMS0-T3 and worked
+out 0 g at layer 0, but did not claim the tray, so the remain% fallback charged
+it from the AMS reading at the stop: 100% at the start, a transient 0% at the
+end, the whole 1000 g spool.
+"""
+
+from datetime import datetime, timezone
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services.usage_tracker import PrintSession, _active_sessions, _track_from_3mf, on_print_complete
+from backend.tests.unit.test_usage_tracker import (
+    _make_archive,
+    _make_assignment,
+    _make_spool,
+    _mock_db_sequential,
+)
+
+pytestmark = [pytest.mark.unit, pytest.mark.asyncio]
+
+# The reporter's plate: one PETG filament in slot 2, mapped to AMS0-T3.
+USAGE = [{"slot_id": 2, "used_g": 28.05, "type": "PETG", "color": ""}]
+MAPPING = [65535, 3]
+
+
+def _stopped_state(
+    remain_at_stop: int = 0, mqtt_mapping: list | None = MAPPING, last_loaded_tray: int = 3
+) -> SimpleNamespace:
+    raw = {"ams": [{"id": 0, "tray": [{"id": 3, "remain": remain_at_stop, "tray_type": "PETG"}]}]}
+    if mqtt_mapping is not None:
+        raw["mapping"] = mqtt_mapping
+    return SimpleNamespace(
+        progress=0,
+        layer_num=0,
+        total_layers=120,
+        tray_now=255,
+        last_loaded_tray=last_loaded_tray,
+        tray_change_log=[],
+        spool_change_log=[],
+        raw_data=raw,
+    )
+
+
+def _threemf(usage):
+    path = MagicMock()
+    path.exists.return_value = True
+    settings = patch("backend.app.core.config.settings")
+    return (
+        settings,
+        patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=usage),
+        patch("backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf", return_value=None),
+        path,
+    )
+
+
+async def _track(usage, ams_mapping, mqtt_mapping=MAPPING, last_loaded_tray=3):
+    printer_manager = MagicMock()
+    printer_manager.get_status.return_value = _stopped_state(
+        mqtt_mapping=mqtt_mapping, last_loaded_tray=last_loaded_tray
+    )
+    handled: set = set()
+    db = _mock_db_sequential([_make_archive(archive_id=32), None, _make_assignment(spool_id=38, ams_id=0, tray_id=3)])
+    settings, extract, layers, path = _threemf(usage)
+    with settings as mock_settings, extract, layers:
+        mock_settings.base_dir.__truediv__ = MagicMock(return_value=path)
+        results = await _track_from_3mf(
+            printer_id=1,
+            archive_id=32,
+            status="failed",
+            print_name="Desiccant core",
+            handled_trays=handled,
+            printer_manager=printer_manager,
+            db=db,
+            ams_mapping=ams_mapping,
+        )
+    return results, handled
+
+
+class TestTheThreeMfClaimsTheTray:
+    async def test_a_mapped_slot_stopped_before_using_any_is_handled(self):
+        results, handled = await _track(USAGE, MAPPING)
+
+        assert results == []
+        assert (0, 3) in handled
+
+    async def test_a_tray_guessed_by_position_is_left_to_the_fallback(self):
+        # No mapping from the dispatch nor from the printer, and no tray loaded.
+        _, handled = await _track(USAGE, None, mqtt_mapping=None, last_loaded_tray=-1)
+
+        assert handled == set()
+
+
+class TestTheCompletion:
+    @pytest.fixture(autouse=True)
+    def _clean(self):
+        _active_sessions.clear()
+        with patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None):
+            yield
+        _active_sessions.clear()
+
+    async def test_the_reporters_print_charges_nothing(self):
+        _active_sessions[1] = PrintSession(
+            printer_id=1,
+            print_name="Desiccant core",
+            started_at=datetime.now(timezone.utc),
+            tray_remain_start={(0, 3): 100},
+            tray_now_at_start=3,
+            ams_mapping=MAPPING,
+        )
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = _stopped_state(remain_at_stop=0)
+        spool = _make_spool(spool_id=38)
+        assignment = _make_assignment(spool_id=38, ams_id=0, tray_id=3)
+        # The 3MF path's archive, queue item and assignment; then what the
+        # fallback would read if it ran: the assignment and the spool.
+        db = _mock_db_sequential([_make_archive(archive_id=32), None, assignment, assignment, spool])
+        settings, extract, layers, path = _threemf(USAGE)
+        with settings as mock_settings, extract, layers:
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=path)
+            results = await on_print_complete(
+                printer_id=1,
+                data={"status": "failed"},
+                printer_manager=printer_manager,
+                db=db,
+                archive_id=32,
+                ams_mapping=MAPPING,
+            )
+
+        assert results == []
+        db.add.assert_not_called()

+ 1 - 0
backend/tests/unit/test_forget_printer.py

@@ -29,6 +29,7 @@ NOT_FORGOTTEN = {
     "_ams_assignment_locks": "a lock carries nothing over; popping one a task holds would let a second be made",
     "_fallback_recovery_locks": "a lock carries nothing over (see its comment on popping)",
     "_HMS_FAILURE_REASONS": "constant",
+    "_HMS_MODULE_NAMES": "constant",
     "_ACTIVE_PRINT_STATES": "constant",
     "_SENSOR_TARGET_KEYS": "constant",
     "PUBLIC_API_ROUTES": "constant",

+ 782 - 0
backend/tests/unit/test_h3c_ams3h.py

@@ -0,0 +1,782 @@
+"""H3C "AMS 3H" (model N10): one nine-slot rack AMS, numbered as Bambu Studio does.
+
+The H3C reports its AMS 3H as ONE unit (id 32, AMS type 6 = Studio's ZMULTI_A)
+with trays 0-8, tray_now as the raw slot, slot k of the tray bit masks at bit
+28+k, and (32 << 8) | k in `mapping` / extruder `snow`. Bambuddy keeps the
+unit as it is and gives its slots Bambu Studio's global tray ids 28-36
+(utils.tray_ids): tray_now 6 is global 34, a print maps slot k as 28+k with
+ams_mapping2 {ams_id: 32, slot_id: k}, and the slot rows are (32, k).
+Commands keep the firmware's own {ams_id: 32, slot_id: k}.
+
+``FRAME`` is trimmed from a live H3C capture (firmware 01.01.03.00, AMS 3H
+02.00.32.82) taken mid-print on 2026-11-06: tray_now "6", slot 7 empty, and
+the hotend bound to slot 6 mounted on the toolhead (nozzle id 0, addr_id 6).
+"""
+
+import asyncio
+import copy
+import json
+from unittest.mock import MagicMock
+
+from backend.app.services.bambu_mqtt import (
+    BambuMQTTClient,
+    ams3h_drying_cooling_temp,
+    resolve_external_spools_in_mapping,
+)
+from backend.app.services.printer_manager import ams_units_to_dicts, resolve_expected_tray
+from backend.app.utils.tray_ids import AmsType, TrayLayout
+
+FRAME = {
+    "command": "push_status",
+    "ams": {
+        "tray_now": "6",
+        "tray_pre": "6",
+        "tray_tar": "6",
+        "tray_exist_bits": "17f0000000",
+        "tray_is_bbl_bits": "17f0000000",
+        "tray_read_done_bits": "17f0000000",
+        "tray_reading_bits": "0",
+        "ams_exist_bits": "2000",
+        "csl": [8193],
+        "insert_flag": True,
+        "power_on_flag": False,
+        "version": 6369,
+        "ams": [
+            {
+                "id": "32",
+                "info": "240842026",
+                "tray": [
+                    {
+                        "id": "0",
+                        "state": 27,
+                        "tray_type": "PLA",
+                        "tray_color": "DE4343FF",
+                        "remain": -1,
+                        "tray_info_idx": "GFL99",
+                        "tag_uid": "0000000000000000",
+                    },
+                    {
+                        "id": "1",
+                        "state": 27,
+                        "tray_type": "PLA",
+                        "tray_color": "FFFFFFFF",
+                        "remain": -1,
+                        "tray_info_idx": "GFL99",
+                        "tag_uid": "0000000000000000",
+                    },
+                    {
+                        "id": "2",
+                        "state": 27,
+                        "tray_type": "PLA",
+                        "tray_color": "757575FF",
+                        "remain": -1,
+                        "tray_info_idx": "GFL99",
+                        "tag_uid": "0000000000000000",
+                    },
+                    {
+                        "id": "3",
+                        "state": 27,
+                        "tray_type": "PLA",
+                        "tray_color": "000000FF",
+                        "remain": -1,
+                        "tray_info_idx": "GFL99",
+                        "tag_uid": "0000000000000000",
+                    },
+                    {
+                        "id": "4",
+                        "state": 27,
+                        "tray_type": "PLA",
+                        "tray_color": "0178BFFF",
+                        "remain": -1,
+                        "tray_info_idx": "GFL99",
+                        "tag_uid": "0000000000000000",
+                    },
+                    {
+                        "id": "5",
+                        "state": 27,
+                        "tray_type": "PLA",
+                        "tray_color": "AE96D4FF",
+                        "remain": -1,
+                        "tray_info_idx": "GFL99",
+                        "tag_uid": "0000000000000000",
+                    },
+                    {
+                        "id": "6",
+                        "state": 27,
+                        "tray_type": "PLA",
+                        "tray_color": "F7D959FF",
+                        "remain": -1,
+                        "tray_info_idx": "GFL99",
+                        "tag_uid": "0000000000000000",
+                    },
+                    {"id": "7", "state": 160},
+                    {
+                        "id": "8",
+                        "state": 27,
+                        "tray_type": "PLA",
+                        "tray_color": "61C680FF",
+                        "remain": -1,
+                        "tray_info_idx": "GFL99",
+                        "tag_uid": "0000000000000000",
+                    },
+                ],
+            }
+        ],
+    },
+    "mapping": [8193, 8195, 8198, 8192, 8200, 8196, 8192, 8194, 8197],
+    "device": {
+        "extruder": {
+            "state": 1,
+            "info": [{"id": 0, "snow": 8198, "spre": 8198, "star": 8198, "hnow": 0, "hpre": 0, "htar": 0}],
+        },
+        "nozzle": {
+            "hc": 511,
+            "info": [
+                {
+                    "id": 16,
+                    "addr_id": 0,
+                    "snow": 8192,
+                    "stat": 168,
+                    "type": "HS01",
+                    "diameter": 0.4,
+                    "color_m": "DE4343FF",
+                    "fila_id": "GFL99",
+                    "tm": 350,
+                    "wear": 0.0,
+                },
+                {
+                    "id": 17,
+                    "addr_id": 1,
+                    "snow": 8193,
+                    "stat": 176,
+                    "type": "HS01",
+                    "diameter": 0.4,
+                    "color_m": "FFFFFFFF",
+                    "fila_id": "GFL99",
+                    "tm": 350,
+                    "wear": 0.0,
+                },
+                {
+                    "id": 18,
+                    "addr_id": 2,
+                    "snow": 8194,
+                    "stat": 184,
+                    "type": "HS01",
+                    "diameter": 0.4,
+                    "color_m": "757575FF",
+                    "fila_id": "GFL99",
+                    "tm": 350,
+                    "wear": 0.0,
+                },
+                {
+                    "id": 19,
+                    "addr_id": 3,
+                    "snow": 8195,
+                    "stat": 184,
+                    "type": "HS01",
+                    "diameter": 0.4,
+                    "color_m": "000000FF",
+                    "fila_id": "GFL99",
+                    "tm": 350,
+                    "wear": 0.0,
+                },
+                {
+                    "id": 20,
+                    "addr_id": 4,
+                    "snow": 8196,
+                    "stat": 184,
+                    "type": "HS01",
+                    "diameter": 0.4,
+                    "color_m": "0178BFFF",
+                    "fila_id": "GFL99",
+                    "tm": 350,
+                    "wear": 0.0,
+                },
+                {
+                    "id": 21,
+                    "addr_id": 5,
+                    "snow": 8197,
+                    "stat": 160,
+                    "type": "HS01",
+                    "diameter": 0.4,
+                    "color_m": "AE96D4FF",
+                    "fila_id": "GFL99",
+                    "tm": 350,
+                    "wear": 0.0,
+                },
+                {
+                    "id": 0,
+                    "addr_id": 6,
+                    "snow": 8198,
+                    "stat": 168,
+                    "type": "HS01",
+                    "diameter": 0.4,
+                    "color_m": "F7D959FF",
+                    "fila_id": "GFL99",
+                    "tm": 350,
+                    "wear": 0.0,
+                },
+                {
+                    "id": 23,
+                    "addr_id": 7,
+                    "snow": 63488,
+                    "stat": 1,
+                    "type": "HT01",
+                    "diameter": 0.4,
+                    "color_m": "00000000",
+                    "fila_id": "",
+                    "tm": 350,
+                    "wear": 0.0,
+                },
+                {
+                    "id": 24,
+                    "addr_id": 8,
+                    "snow": 8200,
+                    "stat": 160,
+                    "type": "HS01",
+                    "diameter": 0.4,
+                    "color_m": "61C680FF",
+                    "fila_id": "GFL99",
+                    "tm": 350,
+                    "wear": 0.0,
+                },
+            ],
+        },
+    },
+}
+
+# Slot colours of FRAME, physical slot 0-8 (7 is empty).
+COLOURS = ["DE4343FF", "FFFFFFFF", "757575FF", "000000FF", "0178BFFF", "AE96D4FF", "F7D959FF", None, "61C680FF"]
+
+
+def _frame() -> dict:
+    return copy.deepcopy(FRAME)
+
+
+def _client(model: str = "H3C") -> BambuMQTTClient:
+    return BambuMQTTClient(ip_address="10.0.0.1", serial_number="H3C", access_code="c", model=model)
+
+
+def _wired(client: BambuMQTTClient) -> BambuMQTTClient:
+    client._client = MagicMock()
+    client.state.connected = True
+    return client
+
+
+def _ingested(model: str = "H3C", frame: dict | None = None) -> BambuMQTTClient:
+    client = _client(model)
+    client._process_message({"print": frame if frame is not None else _frame()})
+    return client
+
+
+def _last_payload(client: BambuMQTTClient) -> dict:
+    return json.loads(client._client.publish.call_args[0][1])["print"]
+
+
+def _unit(client: BambuMQTTClient) -> dict:
+    (unit,) = client.state.raw_data["ams"]
+    return unit
+
+
+def _trays_by_id(unit: dict) -> dict[int, dict]:
+    return {int(t["id"]): t for t in unit.get("tray", [])}
+
+
+class TestIngest:
+    def test_unit_32_stays_one_unit_with_its_nine_trays(self):
+        client = _ingested()
+        unit = _unit(client)
+        assert unit["id"] == "32"
+        trays = _trays_by_id(unit)
+        assert sorted(trays) == list(range(9))
+        for slot, colour in enumerate(COLOURS):
+            assert trays[slot].get("tray_color") == colour
+
+    def test_the_layout_names_the_rack_ams(self):
+        layout = _ingested().tray_layout
+        assert layout.rack_units == ((32, AmsType.ZMULTI_A),)
+        assert [layout.global_tray_id(32, k) for k in range(9)] == list(range(28, 37))
+
+    def test_tray_now_is_the_global_tray_28_plus_slot(self):
+        assert _ingested().state.tray_now == 34  # FRAME: raw slot 6
+        for slot in ("0", "2", "3", "8"):
+            frame = _frame()
+            frame["ams"]["tray_now"] = slot
+            assert _ingested(frame=frame).state.tray_now == 28 + int(slot)
+
+    def test_nothing_on_the_toolhead_stays_255(self):
+        frame = _frame()
+        frame["ams"]["tray_now"] = "255"
+        assert _ingested(frame=frame).state.tray_now == 255
+
+    def test_a_partial_push_is_globalised_too(self):
+        client = _ingested()
+        client._process_message({"print": {"ams": {"tray_now": "2"}}})
+        assert client.state.tray_now == 30
+
+    def test_a_partial_state_27_push_keeps_the_slots_spool(self):
+        # The AMS 3H reports a loaded slot as state 27. The "state != 11 is not
+        # loaded" rule for partial {id, state} pushes (#784) must not empty it,
+        # as it must not for an AMS-HT (#2594).
+        client = _wired(_ingested())
+        client._process_message(
+            {"print": {"command": "push_status", "ams": {"ams": [{"id": "32", "tray": [{"id": "3", "state": 27}]}]}}}
+        )
+        assert _trays_by_id(_unit(client))[3]["tray_type"] == "PLA"
+        assert client.ams_load_filament(31)
+
+    def test_every_full_push_is_globalised_not_just_the_first(self):
+        # The H3C sends the unit list with every push. The push's units plus the
+        # ones already known used to list unit 32 twice, which read as two rack
+        # AMSes, so from the second push on tray_now stayed the raw slot (seen live).
+        client = _ingested()
+        for slot in ("4", "0", "8"):
+            frame = _frame()
+            frame["ams"]["tray_now"] = slot
+            client._process_message({"print": frame})
+            assert client.state.tray_now == 28 + int(slot)
+
+    def test_loaded_trays_are_the_rack_slots_and_inputs(self):
+        layout = _ingested().tray_layout
+        assert layout.is_loaded_tray(28) and layout.is_loaded_tray(36)
+        assert layout.is_loaded_tray(250)  # a per-hotend external input
+        assert not layout.is_loaded_tray(255) and not layout.is_loaded_tray(37)
+        regular = _client(model="X1C").tray_layout
+        assert not regular.is_loaded_tray(30) and not regular.is_loaded_tray(250)
+
+    def test_tray_exist_bits_mark_only_slot_7_empty(self):
+        # "17f0000000": slots 0-6 and 8 at bit 28+k, Studio's get_tray_flag_bit
+        trays = _trays_by_id(_unit(_ingested()))
+        for slot in range(9):
+            assert trays[slot].get("exists") is (slot != 7), slot
+
+    def test_one_extruder_no_ams_extruder_map(self):
+        client = _ingested()
+        assert client.state.ams_extruder_map == {}
+        assert client._is_dual_nozzle is False
+
+    def test_extruder_slot_points_at_unit_32(self):
+        slots = _ingested().state.extruder_slots
+        loaded = next(iter(slots.values()))
+        assert (loaded.ams_id, loaded.slot_id) == (32, 6)
+
+    def test_status_unit_carries_its_ams_type(self):
+        (unit,) = ams_units_to_dicts(_ingested().state.raw_data["ams"], lambda *a: None)
+        assert (unit["id"], unit["ams_type"], len(unit["tray"])) == (32, 6, 9)
+        assert unit["is_ams_ht"] is False
+
+    def test_nozzle_rack_keeps_home_position(self):
+        rack = _ingested().state.nozzle_rack
+        assert len(rack) == 9
+        mounted = [n for n in rack if n["id"] == 0]
+        assert len(mounted) == 1 and mounted[0]["addr_id"] == 6
+        assert {n["addr_id"] for n in rack} == set(range(9))
+
+    def test_version_module_n10a_reaches_the_unit(self):
+        client = _ingested()
+        client._process_message(
+            {"info": {"command": "get_version", "module": [{"name": "n10a", "sw_ver": "02.00.32.82", "sn": "SN3H"}]}}
+        )
+        unit = _unit(client)
+        assert (unit["sw_ver"], unit["module_type"], unit["sn"]) == ("02.00.32.82", "n10a", "SN3H")
+
+    def test_runout_slots_are_global_tray_ids(self):
+        units = ams_units_to_dicts(_ingested().state.raw_data["ams"], lambda *a: None)
+        layout = TrayLayout.from_units(units)
+        assert resolve_expected_tray(6, [(32, False)], None, tray_layout=layout) == 34
+        assert resolve_expected_tray(255, [(32, False)], None, tray_layout=layout) is None
+
+    def test_tray_layout_is_rebuilt_only_when_the_units_change(self):
+        client = _ingested()
+        first = client.tray_layout
+        client._process_message({"print": _frame()})  # same units again
+        assert client.tray_layout is first
+        client.state.raw_data["ams"] = [{"id": "0", "info": "1001"}]
+        assert client.tray_layout is not first
+        assert client.tray_layout.rack_units == ()
+
+    def test_an_offline_h3c_keeps_its_numbering(self):
+        from backend.app.services.printer_manager import printer_tray_layout
+
+        class _Manager:
+            def __init__(self, model):
+                self.model = model
+
+            def get_status(self, printer_id):
+                return None
+
+            def get_model(self, printer_id):
+                return self.model
+
+        h3c = printer_tray_layout(2, _Manager("N10"))
+        assert h3c.slot_key(30) == (32, 2) and h3c.tray_from_slot_key(32, 2) == 30
+        assert h3c.is_external(250) and h3c.slot_key(250) == (255, 5)
+        assert printer_tray_layout(1, _Manager("X1C")) == TrayLayout()
+
+    def test_runout_keeps_an_ams_ht_target_next_to_the_ams3h(self):
+        layout = TrayLayout.from_units([{"id": 32, "ams_type": 6}, {"id": 128, "ams_type": 4}])
+        assert resolve_expected_tray(128, [(32, False), (128, True)], None, tray_layout=layout) == 128
+        assert resolve_expected_tray(3, [(32, False), (128, True)], None, tray_layout=layout) == 31
+
+
+class TestWire:
+    def _setting(self, client: BambuMQTTClient, ams_id: int, tray_id: int) -> bool:
+        return client.ams_set_filament_setting(
+            ams_id=ams_id,
+            tray_id=tray_id,
+            tray_info_idx="GFL99",
+            tray_type="PLA",
+            tray_sub_brands="",
+            tray_color="F7D959FF",
+            nozzle_temp_min=190,
+            nozzle_temp_max=230,
+        )
+
+    def test_filament_setting_targets_unit_32(self):
+        # Verified on the printer: {ams_id 32, tray_id 8, slot_id 8} changed only slot 9.
+        client = _wired(_ingested())
+        assert self._setting(client, 32, 6)
+        sent = _last_payload(client)
+        assert (sent["ams_id"], sent["tray_id"], sent["slot_id"]) == (32, 6, 6)
+
+    def test_reset_slot_targets_unit_32(self):
+        client = _wired(_ingested())
+        assert client.reset_ams_slot(32, 8)
+        sent = _last_payload(client)
+        assert (sent["ams_id"], sent["tray_id"], sent["slot_id"]) == (32, 8, 8)
+
+    def test_k_profile_selection_targets_unit_32(self):
+        client = _wired(_ingested())
+        assert client.extrusion_cali_sel(ams_id=32, tray_id=8, cali_idx=-1, filament_id="GFL99")
+        sent = _last_payload(client)
+        assert (sent["ams_id"], sent["tray_id"], sent["slot_id"]) == (32, 8, 8)
+
+    def test_rfid_refresh_names_the_loaded_ams3h_slot(self):
+        client = _wired(_ingested())  # tray_now 6: global 34
+        ok, message = asyncio.run(client.ams_refresh_tray(32, 4))
+        assert not ok
+        assert message.endswith("Currently loaded: AMS 3H slot 7")
+
+    def test_rfid_reread_targets_unit_32(self):
+        frame = _frame()
+        frame["ams"]["tray_now"] = "255"
+        client = _wired(_ingested(frame=frame))
+        client._rfid_ack_timeout = 0.01
+        ok, _ = asyncio.run(client.ams_refresh_tray(32, 4))
+        assert ok
+        sent = _last_payload(client)
+        assert (sent["command"], sent["ams_id"], sent["slot_id"]) == ("ams_get_rfid", 32, 4)
+
+    def test_regular_ams_unchanged(self):
+        client = _wired(_client(model="X1C"))
+        assert self._setting(client, 0, 2)
+        sent = _last_payload(client)
+        assert (sent["ams_id"], sent["tray_id"], sent["slot_id"]) == (0, 2, 2)
+
+    # Load / unload as Bambu Studio 02.09.01.70 sends them for a direct-connected
+    # hotend rack (PageFilament.cpp): unit 32, extruder 0; a load targets the
+    # unit at slot k, an unload targets 255 at slot 255-k. Verified on the printer.
+    def test_load_feeds_the_slot_into_its_own_hotend(self):
+        client = _wired(_ingested())
+        assert client.ams_load_filament(30)  # AMS 3H slot 3
+        sent = _last_payload(client)
+        assert sent["command"] == "ams_change_filament"
+        assert (sent["ams_id"], sent["target"], sent["slot_id"], sent["extruder_id"]) == (32, 32, 2, 0)
+        assert client.state.pending_tray_target == 30
+
+    def test_unload_names_the_feeder_as_255_minus_slot(self):
+        client = _wired(_ingested())
+        assert client.ams_unload_filament(36)
+        sent = _last_payload(client)
+        assert (sent["ams_id"], sent["target"], sent["slot_id"], sent["extruder_id"]) == (32, 255, 247, 0)
+
+    def test_unload_without_a_slot_takes_the_hotend_on_the_toolhead(self):
+        client = _wired(_ingested())  # FRAME: tray_now "6" -> 34
+        assert client.ams_unload_filament()
+        assert _last_payload(client)["slot_id"] == 255 - 6
+
+    def test_temps_are_the_slot_filament_midpoints(self):
+        frame = _frame()
+        trays = {t["id"]: t for t in frame["ams"]["ams"][0]["tray"]}
+        trays["2"].update(nozzle_temp_min="190", nozzle_temp_max="230")
+        trays["6"].update(nozzle_temp_min="220", nozzle_temp_max="240")
+        client = _wired(_ingested(frame=frame))
+        assert client.ams_load_filament(30)
+        sent = _last_payload(client)
+        assert (sent["tar_temp"], sent["curr_temp"]) == (210, 230)  # slot 2's range; slot 6 is on the toolhead
+
+    def test_temps_unknown_are_minus_one(self):
+        client = _wired(_ingested())
+        assert client.ams_load_filament(28)
+        sent = _last_payload(client)
+        assert (sent["tar_temp"], sent["curr_temp"]) == (-1, -1)
+
+    def test_load_refuses_a_slot_with_no_filament(self):
+        # Studio offers Load only once the AMS has pulled a spool in; FRAME slot 7 is empty.
+        client = _wired(_ingested())
+        assert client.rack_tray_has_filament(28) and not client.rack_tray_has_filament(35)
+        assert client.ams_load_filament(35) is False
+        client._client.publish.assert_not_called()
+
+    def test_only_an_ams3h_slot_loads(self):
+        client = _wired(_ingested())
+        for tray in (254, 250, 2, 128):
+            assert client.ams_load_filament(tray) is False
+        client._client.publish.assert_not_called()
+
+    def test_a_regular_printer_has_no_rack(self):
+        assert _client(model="X1C").tray_layout.rack_units == ()
+
+    def test_captured_mapping2_slot_becomes_the_global_tray(self):
+        detail = [{"ams_id": 32, "slot_id": 6}, {"ams_id": 32, "slot_id": 2}]
+        # Studio's flat value is already 28 + k; Bambuddy's own pre-renumbering
+        # dispatches wrote the raw slot. ams_mapping2 decides either way.
+        assert resolve_external_spools_in_mapping([34, 2], detail, False, _ingested().tray_layout) == [34, 30]
+        assert resolve_external_spools_in_mapping([6, 2], detail, False) == [34, 30]
+
+    def test_dispatch_maps_slots_as_bambu_studio_does(self):
+        client = _wired(_ingested())
+        client.start_print("plate_2.3mf", ams_mapping=[28, 30, 36], use_ams=True)
+        sent = _last_payload(client)
+        assert sent["ams_mapping"] == [28, 30, 36]
+        assert sent["ams_mapping2"] == [
+            {"ams_id": 32, "slot_id": 0},
+            {"ams_id": 32, "slot_id": 2},
+            {"ams_id": 32, "slot_id": 8},
+        ]
+        assert sent["use_ams"] is True
+
+
+class TestUsageAttribution:
+    """Usage and Spoolman tracking see the AMS 3H through its global ids 28-36, its rows as (32, k)."""
+
+    def test_printer_mapping_decodes_to_global_trays(self):
+        from backend.app.services.usage_tracker import _decode_mqtt_mapping
+
+        client = _ingested()
+        # FRAME's mapping: slicer filament i -> (32 << 8) | slot
+        expected = [28 + (v & 0xFF) for v in FRAME["mapping"]]
+        assert _decode_mqtt_mapping(client.state.raw_data["mapping"], client.tray_layout) == expected
+
+    def test_spoolman_tray_lookup_is_keyed_by_global_tray(self):
+        from backend.app.services.spoolman_tracking import _global_tray_id_to_ams_slot, build_ams_tray_lookup
+
+        client = _ingested()
+        lookup = build_ams_tray_lookup(client.state.raw_data)
+        assert sorted(k for k in lookup if k < 128) == list(range(28, 37))
+        assert lookup[36]["tray_type"] == "PLA" and not lookup[35]["tray_type"]  # slot 9 loaded, slot 8 empty
+        # ...and back to the (unit, slot) pair the slot-assignment rows are stored under
+        assert _global_tray_id_to_ams_slot(34, client.tray_layout) == (32, 6)
+        assert _global_tray_id_to_ams_slot(36, client.tray_layout) == (32, 8)
+
+    def test_nothing_lands_in_the_ams_ht_range(self):
+        from backend.app.services.spoolman_tracking import build_ams_tray_lookup
+
+        lookup = build_ams_tray_lookup(_ingested().state.raw_data)
+        assert not any(128 <= k <= 135 for k in lookup)
+
+
+class TestExternalInputs:
+    """The H3C's nine vir_slot entries (247-255) are one external input per hotend."""
+
+    VIR_SLOT = [{"id": str(i), "state": 1, "tray_type": "", "tray_color": "00000000"} for i in range(247, 256)]
+
+    def _with_inputs(self) -> BambuMQTTClient:
+        frame = _frame()
+        frame["vir_slot"] = copy.deepcopy(self.VIR_SLOT)
+        frame["vir_slot"][3].update(tray_type="PETG", tray_color="FF0000FF")  # id 250 -> hotend 5
+        return _ingested(frame=frame)
+
+    def test_all_nine_are_kept_with_their_ids(self):
+        vt = self._with_inputs().state.raw_data["vt_tray"]
+        assert [v["id"] for v in vt] == [str(i) for i in range(247, 256)]
+
+    def test_the_layout_counts_them_as_externals(self):
+        layout = self._with_inputs().tray_layout
+        assert layout.rack
+        assert all(layout.is_external(v) for v in range(247, 256))
+        assert layout.slot_key(250) == (255, 5)
+
+    def test_dual_nozzle_external_slots_untouched(self):
+        client = _client(model="H2D")
+        client._process_message({"print": {"vir_slot": [{"id": "254"}, {"id": "255"}]}})
+        assert [v["id"] for v in client.state.raw_data["vt_tray"]] == ["254", "255"]
+        assert not client.tray_layout.rack
+
+    def test_filament_setting_names_the_input(self):
+        client = _wired(self._with_inputs())
+        assert TestWire()._setting(client, 255, 5)  # stored slot 5 = input 250
+        sent = _last_payload(client)
+        assert (sent["ams_id"], sent["tray_id"], sent["slot_id"]) == (250, 0, 0)
+
+    def test_dispatch_sends_an_input_as_studio_does(self):
+        client = _wired(self._with_inputs())
+        client.start_print("plate_2.3mf", ams_mapping=[28, 250], use_ams=True)
+        sent = _last_payload(client)
+        assert sent["ams_mapping"] == [28, -1]
+        assert sent["ams_mapping2"][1] == {"ams_id": 250, "slot_id": 0}
+        assert sent["use_ams"] is True
+
+    def test_an_input_only_print_turns_the_ams_off(self):
+        client = _wired(self._with_inputs())
+        client.start_print("plate_2.3mf", ams_mapping=[250], use_ams=True)
+        assert _last_payload(client)["use_ams"] is False
+
+    def test_a_captured_input_keeps_its_id(self):
+        layout = self._with_inputs().tray_layout
+        resolved = resolve_external_spools_in_mapping(
+            [-1, -1], [{"ams_id": 250, "slot_id": 0}, {"ams_id": 255, "slot_id": 255}], False, layout
+        )
+        assert resolved == [250, -1]
+
+    def test_printer_mapping_keeps_its_external_inputs(self):
+        # (250 << 8) | 0 is input 250 and (255 << 8) | 0 input 255 (hotend 0), not 254.
+        from backend.app.services.spool_assignment_notifications import _decode_mqtt_mapping_to_global_trays
+        from backend.app.services.usage_tracker import _decode_mqtt_mapping
+
+        layout = self._with_inputs().tray_layout
+        mapping = [(32 << 8) | 1, (250 << 8) | 0, (255 << 8) | 0, 0xFFFF]
+        assert _decode_mqtt_mapping(mapping, layout) == [29, 250, 255, -1]
+        assert _decode_mqtt_mapping_to_global_trays(mapping, layout) == [29, 250, 255]
+
+    def test_tray_now_255_is_not_a_second_external_holder(self):
+        from backend.app.services.usage_tracker import has_second_external_holder
+
+        assert has_second_external_holder(self._with_inputs().state.raw_data) is False
+        assert has_second_external_holder({"vt_tray": [{"id": "254"}, {"id": "255"}]}) is True
+
+    def test_not_a_dual_nozzle_printer(self):
+        from backend.app.services.slot_nozzle import reports_two_nozzles
+
+        assert reports_two_nozzles(self._with_inputs().state) is False
+
+
+class TestChamberHeater:
+    """H3C: M141 only heats with the airduct in heating mode (captured 2026-11-06)."""
+
+    def _sent(self, client: BambuMQTTClient) -> list[dict]:
+        return [json.loads(c[0][1])["print"] for c in client._client.publish.call_args_list]
+
+    def test_heating_mode_is_set_before_m141(self):
+        client = _wired(_ingested())
+        assert client.state.airduct_mode == 0
+        assert client.set_chamber_temperature(45)
+        sent = self._sent(client)
+        assert [s["command"] for s in sent] == ["set_airduct", "gcode_line"]
+        assert sent[0]["modeId"] == 1 and "M141 S45" in sent[1]["param"]
+
+    def test_no_switch_when_already_heating_or_turning_off(self):
+        client = _wired(_ingested())
+        client.state.airduct_mode = 1
+        client.set_chamber_temperature(45)
+        client.state.airduct_mode = 0
+        client.set_chamber_temperature(0)
+        assert [s["command"] for s in self._sent(client)] == ["gcode_line", "gcode_line"]
+
+    def test_h2_series_unchanged(self):
+        client = _wired(_client(model="H2D"))
+        client.set_chamber_temperature(45)
+        assert [s["command"] for s in self._sent(client)] == ["gcode_line"]
+
+
+class TestDrying:
+    """AMS 3H drying is per zone: only a zone holding exactly two slots dries.
+
+    Zones as captured on 2026-11-06 while drying PLA: zone 0 = slots [5, 7]
+    (drying, 675 min left), zone 1 = the other seven (ambient).
+    """
+
+    ZONES = [
+        {
+            "zone_id": 0,
+            "zone_slots": [5, 7],
+            "dry_time": 675,
+            "dry_setting": {"dry_duration": 12, "dry_filament": "PLA", "dry_temperature": 55},
+            "dry_sf_reason": [6],
+            "info": "240842026",
+            "temp": "55.5",
+            "humidity_raw": "25",
+        },
+        {
+            "zone_id": 1,
+            "zone_slots": [0, 1, 2, 3, 4, 6, 8],
+            "dry_time": 675,
+            "dry_setting": {"dry_duration": -1, "dry_filament": "", "dry_temperature": -1},
+            "dry_sf_reason": [6],
+            "info": "240042006",
+            "temp": "27.6",
+            "humidity_raw": "33",
+        },
+    ]
+
+    def _drying(self, zones: list | None = None) -> BambuMQTTClient:
+        frame = _frame()
+        unit = frame["ams"]["ams"][0]
+        unit["zone_info"] = copy.deepcopy(zones if zones is not None else self.ZONES)
+        unit["dry_info"] = "1"
+        return _ingested(frame=frame)
+
+    def test_the_drying_zone_goes_on_the_unit(self):
+        unit = _unit(self._drying())
+        assert unit["dry_time"] == 675
+        assert unit["dry_zone_id"] == 0 and unit["dry_zone_slots"] == [5, 7]
+        assert unit["dry_setting"]["dry_filament"] == "PLA"
+        assert unit["dry_status"] == 2  # Drying, from the unit's info bits 4-7
+
+    def test_start_names_the_zone_and_the_cooling_temp(self):
+        client = _wired(self._drying())
+        client.send_drying_command(32, temp=55, duration=12, mode=1, filament="PLA", rotate_tray=True)
+        sent = _last_payload(client)
+        assert sent["command"] == "ams_filament_drying"
+        assert (sent["ams_id"], sent["zone_id"], sent["mode"]) == (32, 0, 1)
+        assert (sent["temp"], sent["duration"], sent["cooling_temp"]) == (55, 12, 65)
+        assert sent["rotate_tray"] is False  # the AMS 3H can't turn spools
+
+    def test_zone_id_follows_whichever_zone_holds_two_slots(self):
+        zones = copy.deepcopy(self.ZONES)
+        zones[0]["zone_slots"], zones[1]["zone_slots"] = [0, 1, 2, 3, 4, 6, 8], [5, 7]
+        client = _wired(self._drying(zones))
+        client.send_drying_command(32, temp=55, duration=4, mode=1, filament="PLA")
+        assert _last_payload(client)["zone_id"] == 1
+
+    def test_abs_cools_at_its_softening_temperature(self):
+        client = _wired(self._drying())
+        client.send_drying_command(32, temp=80, duration=8, mode=1, filament="ABS")
+        sent = _last_payload(client)
+        assert (sent["ams_id"], sent["cooling_temp"]) == (32, 95)
+
+    def test_stop_names_no_zone(self):
+        client = _wired(self._drying())
+        client.send_drying_command(32, temp=0, duration=0, mode=0)
+        sent = _last_payload(client)
+        assert (sent["ams_id"], sent["mode"], sent["cooling_temp"]) == (32, 0, 0)
+        assert "zone_id" not in sent
+
+    def test_cooling_temps(self):
+        assert ams3h_drying_cooling_temp("PLA") == 65
+        assert ams3h_drying_cooling_temp("PETG-CF") == 75
+        assert ams3h_drying_cooling_temp("TPU") == 70
+        assert ams3h_drying_cooling_temp("WOOD") == 50
+
+    def test_other_models_unchanged(self):
+        client = _wired(_client(model="H2D"))
+        client.send_drying_command(0, temp=55, duration=4, mode=1, filament="PLA")
+        sent = _last_payload(client)
+        assert "zone_id" not in sent and sent["cooling_temp"] == 20 and sent["ams_id"] == 0
+
+    def test_the_header_reads_the_ambient_zone_and_keeps_the_dryers_own_readings(self):
+        # Bambu Studio shows the zone that can't dry as the AMS's humidity and the
+        # drying zone's on the dryer; the unit itself reports neither.
+        unit = _unit(self._drying())
+        assert (unit["humidity_raw"], unit["temp"]) == ("33", "27.6")
+        assert (unit["dry_zone_humidity_raw"], unit["dry_zone_temp"]) == ("25", "55.5")
+
+    def test_the_card_gets_the_dryer_slots_and_readings(self):
+        (card,) = ams_units_to_dicts(self._drying().state.raw_data["ams"], lambda *a: None)
+        assert (card["humidity"], card["temp"]) == (33, 27.6)
+        assert (card["dryer_slots"], card["dryer_humidity"], card["dryer_temp"]) == ([5, 7], 25, 55.5)
+
+    def test_no_zone_info_means_no_dryer_on_the_card(self):
+        (card,) = ams_units_to_dicts(_ingested().state.raw_data["ams"], lambda *a: None)
+        assert (card["dryer_slots"], card["dryer_humidity"], card["dryer_temp"]) == ([], None, None)
+        assert card["humidity"] is None

+ 26 - 0
backend/tests/unit/test_h3c_hms_texts.py

@@ -0,0 +1,26 @@
+"""H3C (serial prefix 32X) fault texts, from Bambu Studio 02.09's hms_en_32X.json.
+
+The catalog was generated from Bambu Studio before the H3C, so its faults --
+module 0x2A is the AMS 3H / PrintTool rack -- showed with no description.
+"""
+
+from backend.app.services.hms_errors import _CATALOG, describe_fault
+
+H3C = "32X"
+
+
+def test_the_catalog_has_the_h3c_model_file():
+    assert H3C in _CATALOG["_source"]["model_file_versions"]
+
+
+def test_unload_prompts_seen_on_the_printer_have_text():
+    # Shown during an unload from bambuddy on 2026-11-07 (slot 1, PrintTool 1).
+    assert describe_fault("2A00200000020004", H3C).startswith("Please pull the filament back through AMS 3H slot 1")
+    assert describe_fault("2A00700000020013", H3C).startswith("Filament of AMS-3H failed to unload from PrintTool 1")
+    assert describe_fault("2A00C003", H3C).startswith("Please pull the filament out of the PrintTool for AMS 3H slot 1")
+    assert describe_fault("2A00801D", H3C).startswith("Failed to unload filament. PrintTool 1 has been placed back")
+
+
+def test_the_h3c_keeps_its_own_wording_where_it_differs():
+    # The H3C has one nozzle on its toolhead: no "left and right" here.
+    assert describe_fault("0500803C", H3C).startswith("The current nozzle setting does not match")

+ 275 - 0
backend/tests/unit/test_h3c_maintenance_firmware_usage.py

@@ -0,0 +1,275 @@
+"""H3C: firmware version source, stopped-print usage, and the maintenance schedule.
+
+- The H3C wiki page is /en/h3c/manual/firmware-release-history and marks
+  versions as id="ota-01010300" with no date.
+- The H3C's mc_percent counts its ~8 min start sequence (22% at layer 0 on a
+  31 min print), so a stopped print is charged by layers printed, not percent.
+- The H3C gets its own maintenance tasks from Bambu's H3C guides, in place of
+  the generic rail, hotend and PTFE ones (and never "Clean Carbon Rods").
+"""
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from backend.app.api.routes.maintenance import (
+    DEFAULT_MAINTENANCE_TYPES,
+    H3C_MAINTENANCE_TYPES,
+    _should_apply_to_printer,
+)
+from backend.app.services.firmware_check import API_KEY_TO_WIKI_PATH, FirmwareCheckService
+from backend.app.utils.printer_models import get_rod_type, usage_progress
+
+# Trimmed from wiki.bambulab.com/en/h3c/manual/firmware-release-history (2026-11-07),
+# with a second, older heading added and an AMS-firmware-style number in prose.
+H3C_WIKI_SAMPLE = """
+<h2 id="ota-01010300" class="toc-header"><a class="toc-anchor" href="#ota-01010300">¶</a> OTA 01.01.03.00</h2>
+<p><strong>New Features</strong></p>
+<ol><li><p>Added support for connecting up to 4 auxiliary AMS / AMS 2 Pro / AMS HT units
+(requires AMS firmware 02.00.19.47).</p></li></ol>
+<h2 id="ota-01010200" class="toc-header"><a class="toc-anchor" href="#ota-01010200">¶</a> OTA 01.01.02.00</h2>
+<p>Bug fixes.</p>
+"""
+
+
+class TestH3CFirmwareSource:
+    def test_wiki_path_is_the_one_bambu_publishes(self):
+        assert API_KEY_TO_WIKI_PATH["h3c"] == "/en/h3c/manual/firmware-release-history"
+
+    @pytest.mark.asyncio
+    async def test_ota_anchors_parse_without_dates(self):
+        svc = FirmwareCheckService()
+        resp = AsyncMock()
+        resp.status_code = 200
+        resp.text = H3C_WIKI_SAMPLE
+        with patch.object(svc._client, "get", AsyncMock(return_value=resp)) as get:
+            versions = await svc._fetch_all_versions_from_wiki("h3c")
+
+        assert get.call_args.args[0] == "https://wiki.bambulab.com/en/h3c/manual/firmware-release-history"
+        assert versions == [("01.01.03.00", None), ("01.01.02.00", None)]
+
+    @pytest.mark.asyncio
+    async def test_dated_anchors_still_win_over_ota_ones(self):
+        svc = FirmwareCheckService()
+        resp = AsyncMock()
+        resp.status_code = 200
+        resp.text = '<h2 id="h-01030000-20260303">01.03.00.00</h2>' + H3C_WIKI_SAMPLE
+        with patch.object(svc._client, "get", AsyncMock(return_value=resp)):
+            versions = await svc._fetch_all_versions_from_wiki("h2d")
+
+        assert versions == [("01.03.00.00", "20260303")]
+
+
+class TestStoppedPrintProgress:
+    def test_h3c_warm_up_is_charged_nothing(self):
+        # Captured: 22% with layer_num 0 during the start sequence.
+        assert usage_progress("H3C", 22.0, 0, 180) == 0.0
+        assert usage_progress("N10", 22.0, None, 180) == 0.0
+
+    def test_h3c_is_charged_by_layers_printed(self):
+        assert usage_progress("H3C", 61.0, 45, 180) == 25.0
+        assert usage_progress("H3C", 99.0, 200, 180) == 100.0
+
+    def test_h3c_without_layer_count_is_charged_nothing(self):
+        assert usage_progress("H3C", 50.0, 90, 0) == 0.0
+
+    @pytest.mark.parametrize("model", ["X1C", "H2D", "P2S", "A1", None, ""])
+    def test_other_models_keep_the_printer_percent(self, model):
+        assert usage_progress(model, 22.0, 0, 180) == 22.0
+        assert usage_progress(model, 0, 0, 0) == 0
+
+    def test_non_string_model_is_not_h3c(self):
+        assert usage_progress(object(), 30, 0, 100) == 30
+
+    def test_completion_payload_reports_layer_progress_for_h3c(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        client = BambuMQTTClient(ip_address="192.168.1.100", serial_number="TEST", access_code="1", model="H3C")
+        done = {}
+        client.on_print_start = lambda data: None
+        client.on_print_complete = lambda data: done.update(data)
+
+        client._process_message(
+            {"print": {"gcode_state": "PREPARE", "gcode_file": "/data/Metadata/plate_2.gcode", "subtask_name": "T"}}
+        )
+        client._process_message({"print": {"gcode_state": "RUNNING", "mc_percent": 22, "layer_num": 0}})
+        client._process_message({"print": {"gcode_state": "FAILED", "mc_percent": 22, "layer_num": 0}})
+
+        assert done.get("status") in ("failed", "aborted", "cancelled")
+        assert done["last_progress"] == 0.0
+        assert done["last_layer_num"] == 0
+
+    def test_completion_payload_unchanged_for_other_models(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        client = BambuMQTTClient(ip_address="192.168.1.100", serial_number="TEST", access_code="1", model="X1C")
+        done = {}
+        client.on_print_start = lambda data: None
+        client.on_print_complete = lambda data: done.update(data)
+
+        client._process_message(
+            {"print": {"gcode_state": "PREPARE", "gcode_file": "/data/Metadata/plate_1.gcode", "subtask_name": "T"}}
+        )
+        client._process_message({"print": {"gcode_state": "RUNNING", "mc_percent": 22, "layer_num": 0}})
+        client._process_message({"print": {"gcode_state": "FAILED", "mc_percent": 22, "layer_num": 0}})
+
+        assert done["last_progress"] == 22
+
+
+class TestH3CMaintenance:
+    def test_h3c_has_linear_rails_not_carbon_rods(self):
+        assert get_rod_type("H3C") == "linear_rail"
+        assert get_rod_type("N10") == "linear_rail"
+
+    @pytest.mark.parametrize("model", ["H3C", "N10"])
+    def test_h3c_gets_its_own_tasks_and_the_shared_ones(self, model):
+        every = DEFAULT_MAINTENANCE_TYPES + H3C_MAINTENANCE_TYPES
+        shown = {t["name"] for t in every if _should_apply_to_printer(t["name"], model)}
+        assert {t["name"] for t in H3C_MAINTENANCE_TYPES} <= shown
+        assert {"Check Belt Tension", "Clean Build Plate"} <= shown
+        for replaced in (
+            "Clean Carbon Rods",
+            "Lubricate Linear Rails",
+            "Clean Linear Rails",
+            "Clean Nozzle/Hotend",
+            "Check PTFE Tube",
+        ):
+            assert replaced not in shown
+
+    @pytest.mark.parametrize("model", ["X1C", "P1S", "H2D", "H2C", "P2S", "A1", "UNKNOWN", None])
+    def test_other_models_never_get_h3c_tasks(self, model):
+        for t in H3C_MAINTENANCE_TYPES:
+            assert _should_apply_to_printer(t["name"], model) is False
+
+    def test_other_models_keep_their_generic_tasks(self):
+        assert _should_apply_to_printer("Lubricate Linear Rails", "H2D") is True
+        assert _should_apply_to_printer("Clean Nozzle/Hotend", "H2D") is True
+        assert _should_apply_to_printer("Check PTFE Tube", "X1C") is True
+        assert _should_apply_to_printer("Clean Carbon Rods", "X1C") is True
+
+    def test_task_definitions_are_complete(self):
+        # The icon names the frontend's iconMap knows (MaintenancePage.tsx).
+        icons = {
+            "Droplet", "Flame", "Ruler", "Sparkles", "Square", "Cable", "Wrench", "Calendar", "Timer", "Cog",
+            "Fan", "Zap", "Wind", "Thermometer", "Layers", "Box", "Target", "RefreshCw", "Settings", "Filter",
+            "CircleDot",
+        }  # fmt: skip
+        names = [t["name"] for t in DEFAULT_MAINTENANCE_TYPES + H3C_MAINTENANCE_TYPES]
+        assert len(names) == len(set(names))
+        for t in H3C_MAINTENANCE_TYPES:
+            assert len(t["name"]) <= 100
+            assert t["interval_type"] in ("hours", "days")
+            assert t["default_interval_hours"] > 0
+            assert t["icon"] in icons
+            assert t["wiki_url"].startswith("https://wiki.bambulab.com/en/h3c/")
+
+    def test_air_filter_uses_bambus_rated_print_hours(self):
+        f = next(t for t in H3C_MAINTENANCE_TYPES if t["name"] == "Replace Air Filter (H3C)")
+        assert (f["default_interval_hours"], f["interval_type"]) == (1440.0, "hours")
+
+
+def _printer(model: str, serial: str):
+    from backend.app.models.printer import Printer
+
+    return Printer(name=model, serial_number=serial, ip_address="192.0.2.9", access_code="1", model=model)
+
+
+async def _type_names(db_session) -> tuple[set[str], set[str]]:
+    """(names GET /maintenance/types lists, names stored) after it ran."""
+    from sqlalchemy import select
+
+    from backend.app.api.routes.maintenance import get_maintenance_types
+    from backend.app.models.maintenance import MaintenanceType
+
+    listed = {t.name for t in await get_maintenance_types(db=db_session, _=None)}
+    stored = {t.name for t in (await db_session.execute(select(MaintenanceType))).scalars()}
+    return listed, stored
+
+
+@pytest.mark.asyncio
+async def test_without_an_h3c_the_types_are_exactly_the_defaults(db_session):
+    db_session.add(_printer("X1C", "SN-X1C-9"))
+    await db_session.commit()
+
+    listed, stored = await _type_names(db_session)
+    assert listed == stored == {t["name"] for t in DEFAULT_MAINTENANCE_TYPES}
+
+
+@pytest.mark.asyncio
+async def test_an_h3c_adds_its_own_types(db_session):
+    h3c = _printer("N10", "SN-H3C-9")
+    db_session.add(h3c)
+    await db_session.commit()
+
+    listed, stored = await _type_names(db_session)
+    expected = {t["name"] for t in DEFAULT_MAINTENANCE_TYPES + H3C_MAINTENANCE_TYPES}
+    assert listed == stored == expected
+
+    # Removing the H3C hides them again; they are kept for a printer added later.
+    await db_session.delete(h3c)
+    await db_session.commit()
+    listed, stored = await _type_names(db_session)
+    assert listed == {t["name"] for t in DEFAULT_MAINTENANCE_TYPES}
+    assert stored == expected
+
+
+@pytest.mark.asyncio
+async def test_ensure_default_types_stores_interval_type_and_wiki_url(db_session):
+    from sqlalchemy import select
+
+    from backend.app.api.routes.maintenance import ensure_default_types
+    from backend.app.models.maintenance import MaintenanceType
+
+    db_session.add(_printer("H3C", "SN-H3C-8"))
+    await db_session.commit()
+    await ensure_default_types(db_session)
+    rows = {t.name: t for t in (await db_session.execute(select(MaintenanceType))).scalars()}
+
+    desiccant = rows["AMS 3H: Check Desiccant"]
+    assert (desiccant.interval_type, desiccant.default_interval_hours) == ("days", 30.0)
+    assert desiccant.wiki_url == "https://wiki.bambulab.com/en/h3c/maintenance/ams_3h"
+    assert desiccant.is_system is True
+    # The generic ones are created as before.
+    assert rows["Clean Build Plate"].interval_type == "hours"
+    assert rows["Clean Build Plate"].wiki_url is None
+
+
+@pytest.mark.asyncio
+async def test_h3c_tasks_count_from_when_the_printer_was_added(db_session):
+    from datetime import datetime, timedelta
+
+    from sqlalchemy import select
+
+    from backend.app.api.routes.maintenance import _get_printer_maintenance_internal
+    from backend.app.models.maintenance import MaintenanceHistory
+    from backend.app.models.printer import Printer
+
+    added = datetime.utcnow() - timedelta(days=4)
+    printer = Printer(
+        name="H3C", serial_number="SN-H3C-1", ip_address="192.0.2.1", access_code="1", model="H3C", created_at=added
+    )
+    db_session.add(printer)
+    await db_session.commit()
+
+    overview = await _get_printer_maintenance_internal(printer.id, db_session)
+    items = {i.maintenance_type_name: i for i in overview.maintenance_items}
+
+    rails = items["Lubricate X/Y Rails (H3C)"]
+    assert rails.is_due is False
+    assert 25.5 < rails.days_until_due < 26.5  # 30-day task, printer added 4 days ago
+    assert items["Clean Extruder Wheel (H3C)"].is_due is False  # 7 days, 4 gone
+    assert overview.due_count == 0
+    assert (await db_session.execute(select(MaintenanceHistory))).scalars().first() is None
+
+
+@pytest.mark.asyncio
+async def test_generic_tasks_keep_never_done_semantics(db_session):
+    from backend.app.api.routes.maintenance import _get_printer_maintenance_internal
+    from backend.app.models.printer import Printer
+
+    printer = Printer(name="X1C", serial_number="SN-X1C-1", ip_address="192.0.2.2", access_code="1", model="X1C")
+    db_session.add(printer)
+    await db_session.commit()
+
+    overview = await _get_printer_maintenance_internal(printer.id, db_session)
+    assert all(i.last_performed_at is None for i in overview.maintenance_items)

+ 104 - 0
backend/tests/unit/test_h3c_mounted_hotend_temp_3442.py

@@ -0,0 +1,104 @@
+"""The H3C's nozzle temperature is the mounted hotend's (#3442).
+
+The H3C swaps hotends on its one extruder. After a swap, ``nozzle_temper``
+keeps reporting the hotend just put back in the rack, while the mounted one's
+temperature is in ``device.extruder.info[0].temp`` (target << 16 | current),
+which Bambu Studio reads over ``nozzle_temper`` (DevExtruderSystem ParseV2_0).
+"""
+
+import pytest
+
+from backend.app.services.bambu_mqtt import BambuMQTTClient
+from backend.tests.unit.test_h3c_ams3h import _frame
+
+pytestmark = pytest.mark.unit
+
+
+def _encoded(target: int, current: int) -> int:
+    return target * 65536 + current
+
+
+def _h3c_frame(extruder_temp: int, nozzle_temper: float = 31.0, nozzle_target: float = 0.0) -> dict:
+    frame = _frame()
+    frame["device"]["extruder"]["info"][0]["temp"] = extruder_temp
+    frame.update(nozzle_temper=nozzle_temper, nozzle_target_temper=nozzle_target)
+    return frame
+
+
+def _client(model: str) -> BambuMQTTClient:
+    return BambuMQTTClient(ip_address="10.0.0.1", serial_number="SN", access_code="c", model=model)
+
+
+class TestH3C:
+    def test_the_mounted_hotend_is_shown_not_the_racked_one(self):
+        client = _client("H3C")
+        client._process_message({"print": _h3c_frame(_encoded(220, 215))})
+
+        temps = client.state.temperatures
+        assert (temps["nozzle"], temps["nozzle_target"], temps["nozzle_heating"]) == (215.0, 220.0, True)
+
+    def test_a_delta_with_only_nozzle_temper_keeps_it(self):
+        client = _client("H3C")
+        client._process_message({"print": _h3c_frame(_encoded(220, 220))})
+        client._process_message({"print": {"nozzle_temper": 40.0, "nozzle_target_temper": 0.0}})
+
+        temps = client.state.temperatures
+        assert (temps["nozzle"], temps["nozzle_target"]) == (220.0, 220.0)
+
+    def test_a_cold_hotend_reads_direct(self):
+        client = _client("H3C")
+        client._process_message({"print": _h3c_frame(28)})
+
+        temps = client.state.temperatures
+        assert (temps["nozzle"], temps["nozzle_target"], temps["nozzle_heating"]) == (28.0, 0.0, False)
+
+    def test_a_rack_printer_of_an_unknown_model_too(self):
+        # Told apart by its AMS 3H and per-hotend inputs, like the tray ids.
+        client = _client("")
+        client._process_message({"print": _h3c_frame(_encoded(220, 215))})
+
+        assert client.state.temperatures["nozzle"] == 215.0
+
+    def test_an_h3c_without_ams_data_yet_ignores_nozzle_temper(self):
+        client = _client("N10")
+        client._process_message({"print": {"nozzle_temper": 40.0, "nozzle_target_temper": 0.0}})
+
+        assert "nozzle" not in client.state.temperatures
+
+
+class TestOtherPrinters:
+    def test_single_nozzle_keeps_nozzle_temper(self):
+        # A one-extruder report on a printer without a rack changes nothing.
+        client = _client("P2S")
+        client._process_message(
+            {
+                "print": {
+                    "nozzle_temper": 200.0,
+                    "nozzle_target_temper": 210.0,
+                    "device": {"extruder": {"state": 1, "info": [{"id": 0, "temp": _encoded(250, 245)}]}},
+                }
+            }
+        )
+
+        temps = client.state.temperatures
+        assert (temps["nozzle"], temps["nozzle_target"]) == (200.0, 210.0)
+
+    def test_h2d_unchanged(self):
+        client = _client("H2D")
+        client._process_message(
+            {
+                "print": {
+                    "nozzle_temper": 33.0,
+                    "device": {
+                        "extruder": {
+                            "state": 2,
+                            "info": [{"id": 0, "temp": _encoded(220, 210)}, {"id": 1, "temp": _encoded(250, 240)}],
+                        }
+                    },
+                }
+            }
+        )
+
+        temps = client.state.temperatures
+        assert (temps["nozzle"], temps["nozzle_target"]) == (240.0, 250.0)
+        assert (temps["nozzle_2"], temps["nozzle_2_target"]) == (210.0, 220.0)

+ 108 - 0
backend/tests/unit/test_h3c_queue_tray_ids_migration.py

@@ -0,0 +1,108 @@
+"""Queued H3C jobs move from the AMS-HT-range ids 128 + k to Studio's 28 + k, once.
+
+Bambuddy without H3C support numbered the AMS 3H (unit 32) as 32 * 4 + k, so a
+job queued for an H3C then carries 128-136. Other printers' jobs keep theirs.
+"""
+
+import json
+
+import pytest
+from sqlalchemy import select, text
+from sqlalchemy.ext.asyncio import async_sessionmaker
+
+from backend.app.core.database import _migrate_h3c_queue_tray_ids
+from backend.app.models.library import LibraryFile
+from backend.app.models.print_queue import PrintQueueItem, PrintQueueVariant
+from backend.app.models.printer import Printer
+
+FLAG = "_migrate_h3c_queue_tray_ids_done"
+
+
+async def _seed(engine) -> dict[str, int]:
+    maker = async_sessionmaker(engine, expire_on_commit=False)
+    async with maker() as db:
+        h3c = Printer(name="H3C", serial_number="SN-Q-H3C", ip_address="192.0.2.30", access_code="1", model="N10")
+        h2d = Printer(name="H2D", serial_number="SN-Q-H2D", ip_address="192.0.2.31", access_code="1", model="H2D")
+        db.add_all([h3c, h2d])
+        await db.flush()
+        items = {
+            "h3c": PrintQueueItem(printer_id=h3c.id, ams_mapping=json.dumps([128, 136, -1, 254, 3])),
+            "model": PrintQueueItem(target_model="H3C", ams_mapping=json.dumps([130])),
+            "h2d": PrintQueueItem(printer_id=h2d.id, ams_mapping=json.dumps([128, 129])),
+            "none": PrintQueueItem(printer_id=h3c.id, ams_mapping=None),
+        }
+        db.add_all(items.values())
+        await db.flush()
+        library_file = LibraryFile(filename="a.3mf", file_path="a.3mf", file_type="3mf", file_size=1)
+        db.add(library_file)
+        await db.flush()
+        variants = {
+            "v_h3c": PrintQueueVariant(
+                queue_item_id=items["model"].id,
+                library_file_id=library_file.id,
+                target_model="H3C",
+                ams_mapping=json.dumps([131, 32]),
+            ),
+            "v_h2d": PrintQueueVariant(
+                queue_item_id=items["model"].id,
+                library_file_id=library_file.id,
+                target_model="H2D",
+                ams_mapping=json.dumps([131]),
+            ),
+        }
+        db.add_all(variants.values())
+        await db.commit()
+        return {**{k: v.id for k, v in items.items()}, **{k: v.id for k, v in variants.items()}}
+
+
+async def _mappings(engine) -> dict[tuple[str, int], list | None]:
+    maker = async_sessionmaker(engine)
+    async with maker() as db:
+        out: dict[tuple[str, int], list | None] = {}
+        for model, name in ((PrintQueueItem, "q"), (PrintQueueVariant, "v")):
+            for row_id, raw in (await db.execute(select(model.id, model.ams_mapping))).tuples():
+                out[(name, row_id)] = json.loads(raw) if raw else None
+        return out
+
+
+async def _run(engine) -> None:
+    async with engine.begin() as conn:
+        await _migrate_h3c_queue_tray_ids(conn)
+
+
+@pytest.mark.asyncio
+async def test_h3c_jobs_are_renumbered_and_others_kept(test_engine):
+    ids = await _seed(test_engine)
+
+    await _run(test_engine)
+
+    got = await _mappings(test_engine)
+    assert got[("q", ids["h3c"])] == [28, 36, -1, 254, 3]
+    assert got[("q", ids["model"])] == [30]
+    assert got[("q", ids["h2d"])] == [128, 129]
+    assert got[("q", ids["none"])] is None
+    assert got[("v", ids["v_h3c"])] == [31, 32]
+    assert got[("v", ids["v_h2d"])] == [131]
+
+
+@pytest.mark.asyncio
+async def test_it_runs_once(test_engine):
+    ids = await _seed(test_engine)
+    await _run(test_engine)
+    async with test_engine.begin() as conn:
+        flag = (await conn.execute(text('SELECT value FROM settings WHERE "key" = :k'), {"k": FLAG})).scalar_one()
+        assert flag == "true"
+        await conn.execute(text("UPDATE print_queue SET ams_mapping = '[129]' WHERE id = :id"), {"id": ids["h3c"]})
+
+    await _run(test_engine)
+
+    assert (await _mappings(test_engine))[("q", ids["h3c"])] == [129]
+
+
+def test_runs_at_startup():
+    """The rewrite only helps if startup runs it."""
+    import inspect
+
+    from backend.app.core.database import _run_migrations
+
+    assert "await _migrate_h3c_queue_tray_ids(conn)" in inspect.getsource(_run_migrations)

+ 156 - 0
backend/tests/unit/test_hms_notified_restart_3486.py

@@ -0,0 +1,156 @@
+"""HMS notifications survive a restart without being sent again (#3486).
+
+Printers keep faults in ``hms[]`` long after they happened. The set of faults
+already notified lived only in memory, so every Bambuddy restart sent each
+still-listed fault once more. And the title's category came from a module table
+that called system faults "Nozzle/Extruder" and AMS Lite faults "Chamber".
+"""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from backend.app import main as main_module
+from backend.app.main import (
+    _hms_error_title,
+    _load_notified_hms_faults,
+    _take_new_hms_faults_and_save,
+)
+from backend.app.models.hms_notified_fault import HmsNotifiedFault
+from backend.app.models.printer import Printer
+
+
+def _fault(full_code: str):
+    return SimpleNamespace(
+        full_code=full_code,
+        attr=int(full_code[:8], 16),
+        code=f"0x{full_code[8:]}",
+        severity=2,
+        description="Described.",
+        actions=[],
+    )
+
+
+TOOLHEAD = _fault("0300010000010008")
+POWER_LOSS = _fault("0500020000020001")
+
+
+@pytest.fixture
+async def printer_id(db_session):
+    printer = Printer(name="P", serial_number="00M09A000000001", ip_address="192.168.1.100", access_code="12345678")
+    db_session.add(printer)
+    await db_session.commit()
+    pid = printer.id
+    main_module._notified_hms_errors.pop(pid, None)
+    main_module._hms_print_state.pop(pid, None)
+    main_module._hms_forget_when_gone.pop(pid, None)
+    yield pid
+    main_module._notified_hms_errors.pop(pid, None)
+    main_module._hms_print_state.pop(pid, None)
+    main_module._hms_forget_when_gone.pop(pid, None)
+
+
+@pytest.fixture(autouse=True)
+def app_session(test_engine):
+    """The service opens its own sessions; point them at the test database."""
+    maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+    with patch("backend.app.services.hms_notified.async_session", maker):
+        yield
+
+
+def _restart(printer_id: int) -> None:
+    """What a restart does to the in-memory tracking."""
+    main_module._notified_hms_errors.pop(printer_id, None)
+    main_module._hms_print_state.pop(printer_id, None)
+    main_module._hms_forget_when_gone.pop(printer_id, None)
+
+
+async def _stored(db_session, printer_id: int) -> set[str]:
+    rows = await db_session.execute(select(HmsNotifiedFault.fault_key).where(HmsNotifiedFault.printer_id == printer_id))
+    return set(rows.scalars())
+
+
+@pytest.mark.asyncio
+class TestRestart:
+    async def test_a_fault_still_listed_is_not_sent_again(self, printer_id):
+        assert await _take_new_hms_faults_and_save(printer_id, [TOOLHEAD, POWER_LOSS], "RUNNING") == [
+            TOOLHEAD,
+            POWER_LOSS,
+        ]
+
+        _restart(printer_id)
+        await _load_notified_hms_faults()
+
+        assert await _take_new_hms_faults_and_save(printer_id, [TOOLHEAD, POWER_LOSS], "RUNNING") == []
+
+    async def test_a_new_fault_after_the_restart_is_still_sent(self, printer_id):
+        await _take_new_hms_faults_and_save(printer_id, [TOOLHEAD], "RUNNING")
+
+        _restart(printer_id)
+        await _load_notified_hms_faults()
+
+        assert await _take_new_hms_faults_and_save(printer_id, [TOOLHEAD, POWER_LOSS], "RUNNING") == [POWER_LOSS]
+
+    async def test_without_the_stored_set_a_restart_sends_again(self, printer_id):
+        # The failure itself: nothing loaded, everything listed is new.
+        await _take_new_hms_faults_and_save(printer_id, [TOOLHEAD], "RUNNING")
+
+        _restart(printer_id)
+
+        assert await _take_new_hms_faults_and_save(printer_id, [TOOLHEAD], "RUNNING") == [TOOLHEAD]
+
+    async def test_a_forgotten_fault_leaves_the_stored_set(self, printer_id, db_session):
+        await _take_new_hms_faults_and_save(printer_id, [TOOLHEAD, POWER_LOSS], "RUNNING")
+        assert await _stored(db_session, printer_id) == {"0300010000010008", "0500020000020001"}
+
+        # A print state change forgets a fault as soon as it is gone.
+        await _take_new_hms_faults_and_save(printer_id, [TOOLHEAD], "FINISH")
+
+        db_session.expire_all()
+        assert await _stored(db_session, printer_id) == {"0300010000010008"}
+
+        _restart(printer_id)
+        await _load_notified_hms_faults()
+        assert await _take_new_hms_faults_and_save(printer_id, [TOOLHEAD, POWER_LOSS], "FINISH") == [POWER_LOSS]
+
+    async def test_a_failed_write_does_not_hold_back_the_notification(self, printer_id):
+        with patch(
+            "backend.app.services.hms_notified.save_notified_hms_faults",
+            AsyncMock(side_effect=RuntimeError("database is locked")),
+        ):
+            assert await _take_new_hms_faults_and_save(printer_id, [TOOLHEAD], "RUNNING") == [TOOLHEAD]
+
+    async def test_a_failed_load_starts_empty(self, printer_id):
+        with patch(
+            "backend.app.services.hms_notified.load_notified_hms_faults",
+            AsyncMock(side_effect=RuntimeError("no such table")),
+        ):
+            await _load_notified_hms_faults()
+
+        assert printer_id not in main_module._notified_hms_errors
+
+
+class TestTitle:
+    @pytest.mark.parametrize(
+        ("module", "title"),
+        [
+            # USB, MQTT and file errors were titled "Nozzle/Extruder Error".
+            (0x05, "System Error"),
+            # Cameras and lidar, not motion.
+            (0x0C, "Camera Error"),
+            # An A1 mini's AMS Lite runouts were titled "Chamber Error".
+            (0x12, "AMS Lite Error"),
+            (0x03, "Print/Task Error"),
+            (0x07, "AMS/Filament Error"),
+            (0x18, "AMS-HT Error"),
+            (0x2A, "AMS 3H Error"),
+            (0x1A, "Tool Changer Error"),
+            # Unknown modules get a plain title rather than a hex number.
+            (0x7F, "Printer Error"),
+        ],
+    )
+    def test_the_title_names_the_right_subsystem(self, module, title):
+        assert _hms_error_title(module) == title

+ 85 - 0
backend/tests/unit/test_nozzle_print_time_1908.py

@@ -0,0 +1,85 @@
+"""H2C per-nozzle print time (#1908).
+
+Recent H2C firmware reports how long each nozzle has printed as ``p_t`` (seconds)
+on its ``device.nozzle.info`` entry. It is carried as ``print_time_s`` through
+the MQTT state, the WebSocket status push and the REST status, and stays None
+where the firmware does not report it.
+"""
+
+import pytest
+
+from backend.app.schemas.printer import NozzleRackSlot
+from backend.app.services.bambu_mqtt import BambuMQTTClient, PrinterState, nozzle_print_time_s
+from backend.app.services.printer_manager import printer_state_to_dict
+
+
+def _nozzle(nozzle_id: int, **extra) -> dict:
+    return {"id": nozzle_id, "type": "HS01", "diameter": 0.4, "tm": 350, "wear": 0, "stat": 184, **extra}
+
+
+def _ingested(*nozzles: dict) -> BambuMQTTClient:
+    client = BambuMQTTClient(ip_address="10.0.0.1", serial_number="H2C", access_code="c", model="H2C")
+    client._process_message({"print": {"device": {"nozzle": {"info": list(nozzles)}}}})
+    return client
+
+
+class TestParse:
+    @pytest.mark.parametrize(
+        ("raw", "expected"),
+        [
+            (0, 0),
+            (7265, 7265),
+            (7265.9, 7265),
+            (None, None),
+            (-1, None),
+            ("7265", None),
+            (True, None),
+        ],
+    )
+    def test_only_a_non_negative_number_is_a_print_time(self, raw, expected):
+        assert nozzle_print_time_s(raw) == expected
+
+    def test_each_nozzle_keeps_its_own_print_time(self):
+        rack = _ingested(_nozzle(0, p_t=3600), _nozzle(16, p_t=90061), _nozzle(17)).state.nozzle_rack
+        assert {n["id"]: n["print_time_s"] for n in rack} == {0: 3600, 16: 90061, 17: None}
+
+    def test_firmware_without_p_t_leaves_it_unset(self):
+        (nozzle,) = _ingested(_nozzle(0)).state.nozzle_rack
+        assert nozzle["print_time_s"] is None
+
+
+class TestSerialise:
+    def test_the_websocket_push_carries_it(self):
+        state = PrinterState(connected=True, state="IDLE")
+        state.nozzle_rack = _ingested(_nozzle(16, p_t=7200)).state.nozzle_rack
+        (slot,) = printer_state_to_dict(state)["nozzle_rack"]
+        assert slot["print_time_s"] == 7200
+
+    def test_the_push_and_the_rest_schema_have_the_same_fields(self):
+        # The frontend shallow-merges pushes over the REST status, so a field
+        # the push leaves out goes stale on the first push.
+        state = PrinterState(connected=True, state="IDLE")
+        state.nozzle_rack = _ingested(_nozzle(16, p_t=7200)).state.nozzle_rack
+        (slot,) = printer_state_to_dict(state)["nozzle_rack"]
+        assert set(slot) == set(NozzleRackSlot.model_fields)
+        assert NozzleRackSlot(**slot).print_time_s == 7200
+
+
+class TestRestStatus:
+    @pytest.mark.asyncio
+    async def test_the_status_route_carries_it(self, async_client, printer_factory):
+        # What the page has before the first push arrives.
+        from unittest.mock import MagicMock, patch
+
+        printer = await printer_factory()
+        state = _ingested(_nozzle(0, p_t=3600), _nozzle(16)).state
+        state.connected = True
+        state.state = "IDLE"
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_status = MagicMock(return_value=state)
+            mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/status")
+
+        assert response.status_code == 200
+        assert {n["id"]: n["print_time_s"] for n in response.json()["nozzle_rack"]} == {0: 3600, 16: None}

+ 52 - 19
backend/tests/unit/test_opentag3d.py

@@ -13,6 +13,33 @@ from backend.app.services.opentag3d import (
 )
 
 
+def _first_ndef_message(data: bytes) -> bytes | None:
+    """Walk the data area from page 4 the way an NFC Forum Type 2 reader does.
+
+    Returns the value of the first NDEF Message TLV (0x03), or None when the
+    walk reaches the terminator (0xFE) or the end without one.
+    """
+    i = 0
+    while i < len(data):
+        tlv_type = data[i]
+        if tlv_type == 0x00:  # NULL TLV, no length
+            i += 1
+            continue
+        if tlv_type == 0xFE:  # Terminator
+            return None
+        if i + 1 >= len(data):
+            return None
+        length = data[i + 1]
+        start = i + 2
+        if length == 0xFF:
+            length = int.from_bytes(data[i + 2 : i + 4], "big")
+            start = i + 4
+        if tlv_type == 0x03:
+            return data[start : start + length]
+        i = start + length
+    return None
+
+
 def _make_spool(**kwargs):
     """Create a mock Spool with default values."""
     defaults = {
@@ -105,27 +132,33 @@ class TestBuildPayload:
 
 
 class TestEncodeOpentag3d:
-    def test_starts_with_cc(self):
-        data = encode_opentag3d(_make_spool())
-        assert data[:4] == bytes([0xE1, 0x10, 0x12, 0x00])
-
-    def test_tlv_header(self):
+    def test_starts_with_the_ndef_tlv(self):
+        # #3500: page 4 is data on NTAG21x. A CC copy there hid the message.
         data = encode_opentag3d(_make_spool())
         # TLV type = 0x03
-        assert data[4] == 0x03
+        assert data[0] == 0x03
         # TLV length = 3 (record header) + 21 (mime type) + 102 (payload) = 126
-        assert data[5] == 126
+        assert data[1] == 126
 
     def test_ndef_record_header(self):
         data = encode_opentag3d(_make_spool())
-        # Record starts after CC(4) + TLV(2) = offset 6
-        assert data[6] == 0xD2  # MB|ME|SR + TNF=MIME
-        assert data[7] == len(OPENTAG3D_MIME_TYPE)  # type length = 21
-        assert data[8] == PAYLOAD_SIZE  # payload length = 102
+        # Record starts after TLV(2) = offset 2
+        assert data[2] == 0xD2  # MB|ME|SR + TNF=MIME
+        assert data[3] == len(OPENTAG3D_MIME_TYPE)  # type length = 21
+        assert data[4] == PAYLOAD_SIZE  # payload length = 102
+
+    def test_a_type_2_reader_finds_the_record(self):
+        data = encode_opentag3d(_make_spool(material="PLA", brand="Polymaker"))
+
+        record = _first_ndef_message(data)
+
+        assert record is not None
+        assert record[3 : 3 + 21] == OPENTAG3D_MIME_TYPE
+        assert record[3 + 21 :][0x02:0x05] == b"PLA"
 
     def test_mime_type(self):
         data = encode_opentag3d(_make_spool())
-        mime = data[9:30]
+        mime = data[5:26]
         assert mime == b"application/opentag3d"
 
     def test_ends_with_terminator(self):
@@ -134,8 +167,8 @@ class TestEncodeOpentag3d:
 
     def test_total_size(self):
         data = encode_opentag3d(_make_spool())
-        # CC(4) + TLV(2) + header(3) + type(21) + payload(102) + terminator(1) = 133
-        assert len(data) == 133
+        # TLV(2) + header(3) + type(21) + payload(102) + terminator(1) = 129
+        assert len(data) == 129
 
     def test_fits_ntag213(self):
         """NTAG213 has 36 writable pages (144 bytes). Our data must fit."""
@@ -255,11 +288,11 @@ class TestBuildPayloadFromDict:
 class TestEncodeOpentag3dFromMapped:
     def test_total_size(self):
         data = encode_opentag3d_from_mapped(MINIMAL_MAPPED)
-        assert len(data) == 133
+        assert len(data) == 129
 
-    def test_starts_with_cc(self):
+    def test_starts_with_the_ndef_tlv(self):
         data = encode_opentag3d_from_mapped(MINIMAL_MAPPED)
-        assert data[:4] == bytes([0xE1, 0x10, 0x12, 0x00])
+        assert data[:2] == bytes([0x03, 126])
 
     def test_ends_with_terminator(self):
         data = encode_opentag3d_from_mapped(MINIMAL_MAPPED)
@@ -317,5 +350,5 @@ class TestEncodeOpentag3dFromMapped:
         }
         mapped = _map_spoolman_spool(raw)
         data = encode_opentag3d_from_mapped(mapped)
-        assert len(data) == 133
-        assert data[:4] == bytes([0xE1, 0x10, 0x12, 0x00])
+        assert len(data) == 129
+        assert data[:2] == bytes([0x03, 126])

+ 115 - 0
backend/tests/unit/test_opentag3d_v2_3500.py

@@ -0,0 +1,115 @@
+"""OpenTag3D 2.004 encoding for tags with room for it (#3500).
+
+The spec moved every field in 2.000 and dropped NTAG213, so SpoolBuddy writes
+v2 on an NTAG215/216 and keeps v1 for an NTAG213. Offsets are from
+https://opentag3d.info/spec (2.004).
+"""
+
+import struct
+from types import SimpleNamespace
+
+from backend.app.services.opentag3d import (
+    OPENTAG3D_MIME_TYPE,
+    V2_PAYLOAD_SIZE,
+    encode_opentag3d,
+    encode_opentag3d_v2,
+    encode_opentag3d_v2_from_mapped,
+)
+
+NTAG213_DATA_AREA = 0x12 * 8  # 144
+NTAG215_DATA_AREA = 0x3E * 8  # 496
+
+
+def _spool(**overrides):
+    fields = {
+        "material": "PETG",
+        "subtype": "HF",
+        "brand": "Polymaker",
+        "color_name": "Jade White",
+        "rgba": "00AE42FF",
+        "extra_colors": "ff0000,00ff0080",
+        "label_weight": 1000,
+        "core_weight": 250,
+        "nozzle_temp_min": 230,
+        "nozzle_temp_max": 260,
+    }
+    fields.update(overrides)
+    return SimpleNamespace(**fields)
+
+
+def _payload(message: bytes) -> bytes:
+    """The record's payload, after TLV(2) + header(3) + type(21)."""
+    assert message[0] == 0x03
+    assert message[2] == 0xD2
+    assert message[5:26] == OPENTAG3D_MIME_TYPE
+    length = message[4]
+    return message[26 : 26 + length]
+
+
+def test_the_fields_sit_at_the_2004_offsets():
+    p = _payload(encode_opentag3d_v2(_spool()))
+
+    assert struct.unpack_from(">H", p, 0x00)[0] == 2004
+    assert p[0x02:0x07] == b"PETG "
+    assert p[0x07:0x0C] == b"HF   "
+    assert p[0x0C:0x1C] == b"Polymaker".ljust(16)
+    assert p[0x1C:0x3C] == b"Jade White".ljust(32)
+    assert p[0x3C:0x40] == bytes.fromhex("00AE42FF")
+    assert struct.unpack_from(">H", p, 0x8C)[0] == 1750
+    assert (p[0x90], p[0x91], p[0x92]) == (46, 46, 52)  # 230, 230, 260 C / 5
+    assert struct.unpack_from(">H", p, 0x9E)[0] == 1000
+    assert struct.unpack_from(">H", p, 0xA0)[0] == 250
+
+
+def test_extra_colours_fill_colours_2_to_4():
+    p = _payload(encode_opentag3d_v2(_spool(extra_colors="ff0000,00ff0080,0000ff,123456")))
+
+    assert p[0x40:0x44] == bytes.fromhex("FF0000FF")  # 6 hex chars = opaque
+    assert p[0x44:0x48] == bytes.fromhex("00FF0080")
+    assert p[0x48:0x4C] == bytes.fromhex("0000FFFF")
+    # Only three more colours fit; the fourth extra one is dropped.
+    assert p[0x4C:0x50] == bytes(4)
+
+
+def test_fields_bambuddy_does_not_track_stay_unset():
+    p = _payload(encode_opentag3d_v2(_spool()))
+
+    assert p[0x93] == 0  # chamber: no heating
+    assert p[0x94] == 0  # bed
+    assert struct.unpack_from(">H", p, 0x9C)[0] == 0  # density
+
+
+def test_missing_values_encode_as_zero():
+    p = _payload(
+        encode_opentag3d_v2(
+            _spool(extra_colors=None, nozzle_temp_min=None, nozzle_temp_max=None, core_weight=None, brand=None)
+        )
+    )
+
+    assert p[0x0C:0x1C] == b" " * 16
+    assert p[0x40:0x4C] == bytes(12)
+    assert (p[0x90], p[0x91], p[0x92]) == (0, 0, 0)
+    assert struct.unpack_from(">H", p, 0xA0)[0] == 0
+
+
+def test_a_long_name_is_cut_without_splitting_a_character():
+    p = _payload(encode_opentag3d_v2(_spool(color_name="Grün" * 10)))
+
+    name = p[0x1C:0x3C]
+    assert len(name) == 32
+    name.decode("utf-8")  # no half character at the cut
+
+
+def test_v2_fits_an_ntag215_but_not_an_ntag213():
+    v2 = encode_opentag3d_v2(_spool())
+
+    assert len(v2) == 2 + 3 + 21 + V2_PAYLOAD_SIZE + 1
+    assert NTAG213_DATA_AREA < len(v2) <= NTAG215_DATA_AREA
+    # v1 still fits the NTAG213 it is kept for.
+    assert len(encode_opentag3d(_spool())) <= NTAG213_DATA_AREA
+
+
+def test_the_spoolman_path_encodes_the_same():
+    spool = _spool()
+
+    assert encode_opentag3d_v2_from_mapped(vars(spool)) == encode_opentag3d_v2(spool)

+ 2 - 0
backend/tests/unit/test_outbound_url_ssrf_guards.py

@@ -640,6 +640,8 @@ GUARDED_BODY_URLS = {
     ("OIDCProviderCreate", "icon_url"),
     ("OIDCProviderUpdate", "issuer_url"),
     ("OIDCProviderUpdate", "icon_url"),
+    ("WLEDPresetListRequest", "base_url"),  # WLEDConfig LAN-service guard
+    ("WLEDPresetTestRequest", "base_url"),  # Same validated WLED LAN-service URL
     # Gitea/Forgejo derive their API base from this and request it with the
     # stored token, so it is a real fetch target — guarded in
     # github_backup._enforce_private_repo, which both POST and PATCH funnel through.

+ 62 - 0
backend/tests/unit/test_printer_models.py

@@ -3,6 +3,12 @@
 import pytest
 
 from backend.app.services.camera import get_camera_port, supports_rtsp
+from backend.app.services.firmware_check import API_KEY_TO_DEV_MODEL, MODEL_TO_API_KEY
+from backend.app.services.printer_manager import (
+    supports_airduct,
+    supports_chamber_heater,
+    supports_chamber_temp,
+)
 from backend.app.utils.printer_models import (
     CARBON_ROD_MODELS,
     LINEAR_RAIL_MODELS,
@@ -12,6 +18,7 @@ from backend.app.utils.printer_models import (
     has_external_storage,
     has_remote_storage_toggle,
     is_dual_nozzle_model,
+    is_nozzle_rack_model,
     normalize_printer_model,
     normalize_printer_model_id,
     supports_nozzle_flow_type,
@@ -173,6 +180,56 @@ class TestA2LModel:
         assert "N9" not in STEEL_ROD_MODELS
 
 
+class TestH3CModel:
+    """H3C printer support: recognition and printer control only.
+
+    Internal model code "N10" comes from BambuStudio 02.09.01.70's
+    ``resources/profiles/BBL/machine/Bambu Lab H3C.json``. The H3C has nine
+    tool heads, an active chamber heater (65 °C) and Wi-Fi only (no Ethernet).
+    BambuStudio models the nine tools as ONE extruder with nine nozzles, so the
+    H3C must stay out of the dual-nozzle and H2C nozzle-rack groups: both carry
+    wire-ID assumptions (0 = right / 1 = left, rack IDs 16-21) that do not
+    describe this printer.
+    """
+
+    def test_model_code_maps_to_display_name(self):
+        assert normalize_printer_model_id("N10") == "H3C"
+        assert normalize_printer_model("Bambu Lab H3C") == "H3C"
+
+    @pytest.mark.parametrize("model", ["H3C", "N10"])
+    def test_has_no_ethernet(self, model: str):
+        assert has_ethernet(model) is False
+
+    @pytest.mark.parametrize("model", ["H3C", "N10"])
+    def test_camera_uses_rtsp(self, model: str):
+        assert supports_rtsp(model) is True
+        assert get_camera_port(model) == 322
+
+    @pytest.mark.parametrize("model", ["H3C", "N10"])
+    def test_is_not_dual_nozzle_or_rack(self, model: str):
+        assert is_dual_nozzle_model(model) is False
+        assert is_nozzle_rack_model(model) is False
+
+    @pytest.mark.parametrize("model", ["H3C", "N10"])
+    def test_chamber_sensor_heater_and_airduct(self, model: str):
+        assert supports_chamber_temp(model) is True
+        assert supports_chamber_heater(model) is True
+        assert supports_airduct(model) is True
+
+    def test_rod_type_is_linear_rail(self):
+        """Guide rails on X/Y and Z lead screws, per Bambu's H3C XYZ lubrication guide.
+
+        Unknown (None) made maintenance fall back to "Clean Carbon Rods".
+        """
+        assert get_rod_type("H3C") == "linear_rail"
+        assert get_rod_type("N10") == "linear_rail"
+
+    def test_firmware_check_keys(self):
+        assert MODEL_TO_API_KEY["H3C"] == "h3c"
+        assert MODEL_TO_API_KEY["N10"] == "h3c"
+        assert API_KEY_TO_DEV_MODEL["h3c"] == "N10"
+
+
 class TestA1SeriesModelIds:
     """Regression guard for the A1-family internal-code → display-name map.
 
@@ -292,6 +349,11 @@ class TestHasRemoteStorageToggle:
     def test_p1_series_has_no_reachable_toggle(self, model: str):
         assert has_remote_storage_toggle(model) is False
 
+    @pytest.mark.parametrize("model", ["A2L", "a2l", "N9"])
+    def test_a2l_has_no_reachable_toggle(self, model: str):
+        # #3499: no option on the printer or in either slicer.
+        assert has_remote_storage_toggle(model) is False
+
     @pytest.mark.parametrize(
         "model",
         ["X1C", "X1E", "X1", "P2S", "H2D", "H2D Pro", "H2C", "H2S", "X2D", "A1", "A1 Mini"],

+ 43 - 1
backend/tests/unit/test_printer_schema.py

@@ -9,7 +9,7 @@ the schema normalizes it on input.
 import pytest
 from pydantic import ValidationError
 
-from backend.app.schemas.printer import PrinterCreate
+from backend.app.schemas.printer import PrinterCreate, WLEDConfig, WLEDPresets
 
 
 def _make(serial: str) -> PrinterCreate:
@@ -40,3 +40,45 @@ def test_already_normalized_serial_unchanged():
 def test_blank_serial_number_rejected():
     with pytest.raises(ValidationError):
         _make("   ")
+
+
+def test_wled_config_normalizes_url_and_accepts_optional_mappings():
+    config = WLEDConfig(
+        enabled=True,
+        base_url=" http://wled.local/ ",
+        presets=WLEDPresets(printing=3, paused=None),
+        finished_timeout_seconds=120,
+    )
+
+    assert config.base_url == "http://wled.local"
+    assert config.presets.printing == 3
+    assert config.presets.paused is None
+
+
+@pytest.mark.parametrize("preset", [0, 251])
+def test_wled_preset_outside_supported_range_rejected(preset):
+    with pytest.raises(ValidationError):
+        WLEDPresets(idle=preset)
+
+
+def test_enabled_wled_requires_url():
+    with pytest.raises(ValidationError):
+        WLEDConfig(enabled=True)
+
+
+@pytest.mark.parametrize(
+    "url",
+    [
+        "file:///etc/passwd",
+        "http://169.254.169.254/latest/meta-data",
+        "http://user:password@wled.local",
+    ],
+)
+def test_wled_url_uses_lan_service_safety_policy(url):
+    with pytest.raises(ValidationError):
+        WLEDConfig(base_url=url)
+
+
+def test_wled_finished_timeout_is_bounded():
+    with pytest.raises(ValidationError):
+        WLEDConfig(finished_timeout_seconds=86401)

+ 288 - 0
backend/tests/unit/test_process_changes_recorded_3497.py

@@ -0,0 +1,288 @@
+"""Settings Bambuddy puts on a process preset are marked in the sliced 3MF (#3497).
+
+The slicer CLI writes ``different_settings_to_system`` but leaves it empty, so
+a slice-modal override (40% gyroid) was in ``project_settings.config`` without
+being listed as changed from the system preset. Bambu Studio opened the
+project with the stock preset's values instead, and a re-slice there dropped
+the override.
+"""
+
+import io
+import json
+import zipfile
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.api.routes.library import _run_slicer_with_fallback
+from backend.app.schemas.slicer import PresetRef, SliceRequest
+from backend.app.schemas.slicer_presets import UnifiedPreset
+from backend.app.services.design_settings import changed_process_keys, record_process_changes
+
+pytestmark = pytest.mark.unit
+
+_SETTINGS = "Metadata/project_settings.config"
+
+
+def _sliced_3mf(changed: list[str], filaments: int = 1, **extra) -> bytes:
+    buffer = io.BytesIO()
+    with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
+        archive.writestr("3D/3dmodel.model", "<model/>")
+        archive.writestr("Metadata/plate_1.gcode", "M1002 gcode_claim_action : 1\n")
+        archive.writestr(
+            _SETTINGS,
+            json.dumps(
+                {
+                    "machine_start_gcode": "M1002 gcode_claim_action : 1\nM620 M\n",
+                    "print_settings_id": "0.20mm Standard @BBL A1M",
+                    "filament_settings_id": ["Bambu PLA Basic @BBL A1M"] * filaments,
+                    "different_settings_to_system": changed,
+                    **extra,
+                }
+            ),
+        )
+    return buffer.getvalue()
+
+
+def _config(zip_bytes: bytes) -> dict:
+    with zipfile.ZipFile(io.BytesIO(zip_bytes)) as archive:
+        return json.loads(archive.read(_SETTINGS))
+
+
+class TestRecord:
+    def test_the_keys_land_in_the_process_entry(self):
+        out = record_process_changes(_sliced_3mf(["", "", ""]), ["sparse_infill_density", "sparse_infill_pattern"])
+
+        assert _config(out)["different_settings_to_system"] == ["sparse_infill_density;sparse_infill_pattern", "", ""]
+
+    def test_keys_already_listed_stay(self):
+        out = record_process_changes(
+            _sliced_3mf(["wall_loops;enable_support", "filament_flow_ratio", "machine_start_gcode"]),
+            ["sparse_infill_density", "wall_loops"],
+        )
+
+        assert _config(out)["different_settings_to_system"] == [
+            "enable_support;sparse_infill_density;wall_loops",
+            "filament_flow_ratio",
+            "machine_start_gcode",
+        ]
+
+    @pytest.mark.parametrize("filaments", [2, 3, 4])
+    def test_only_the_process_entry_changes_with_several_filaments(self, filaments):
+        changed = [""] * (filaments + 2)
+
+        out = record_process_changes(_sliced_3mf(changed, filaments), ["sparse_infill_density"])
+
+        assert _config(out)["different_settings_to_system"] == ["sparse_infill_density"] + [""] * (filaments + 1)
+
+    def test_nothing_to_record_leaves_the_file_alone(self):
+        source = _sliced_3mf(["", "", ""])
+
+        assert record_process_changes(source, []) is source
+
+    def test_a_list_that_does_not_match_the_filaments_is_left_alone(self):
+        source = _sliced_3mf(["", ""])
+
+        assert record_process_changes(source, ["sparse_infill_density"]) is source
+
+    def test_a_file_without_the_list_is_left_alone(self):
+        buffer = io.BytesIO()
+        with zipfile.ZipFile(buffer, "w") as archive:
+            archive.writestr(_SETTINGS, json.dumps({"filament_settings_id": ["x"]}))
+        source = buffer.getvalue()
+
+        assert record_process_changes(source, ["sparse_infill_density"]) is source
+
+    def test_not_a_zip_is_left_alone(self):
+        assert record_process_changes(b"; plain gcode\n", ["sparse_infill_density"]) == b"; plain gcode\n"
+
+    def test_every_other_member_is_kept(self):
+        source = _sliced_3mf(["", "", ""], layer_height="0.2")
+
+        out = record_process_changes(source, ["sparse_infill_density"])
+
+        with zipfile.ZipFile(io.BytesIO(source)) as before, zipfile.ZipFile(io.BytesIO(out)) as after:
+            assert before.namelist() == after.namelist()
+            assert before.read("Metadata/plate_1.gcode") == after.read("Metadata/plate_1.gcode")
+        assert _config(out)["layer_height"] == "0.2"
+
+
+class TestChangedKeys:
+    def test_added_and_changed_keys(self):
+        before = json.dumps({"name": "x", "inherits": "x", "wall_loops": "2"})
+        after = json.dumps({"name": "x", "inherits": "x", "wall_loops": "3", "sparse_infill_density": "40%"})
+
+        assert changed_process_keys(before, after) == ["sparse_infill_density", "wall_loops"]
+
+    def test_unparseable_json_records_nothing(self):
+        assert changed_process_keys("{", json.dumps({"a": "1"})) == []
+
+
+def _request(
+    process_source: str = "standard", process_id: str = "0.20mm Standard @BBL A1M", **overrides
+) -> SliceRequest:
+    return SliceRequest(
+        printer_preset=PresetRef(source="standard", id="Bambu Lab A1 mini 0.4 nozzle"),
+        process_preset=PresetRef(source=process_source, id=process_id),
+        filament_presets=[PresetRef(source="standard", id="Bambu PLA Basic @BBL A1M")],
+        export_3mf=True,
+        **overrides,
+    )
+
+
+def _source_3mf() -> bytes:
+    buffer = io.BytesIO()
+    with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
+        archive.writestr("3D/3dmodel.model", "<model/>")
+        archive.writestr(
+            _SETTINGS,
+            json.dumps(
+                {
+                    "enable_support": "1",
+                    "support_type": "tree(auto)",
+                    "filament_settings_id": ["Bambu PLA Basic @BBL A1M"],
+                    "different_settings_to_system": ["enable_support;support_type", "", ""],
+                }
+            ),
+        )
+        archive.writestr("Metadata/model_settings.config", "<config><object id='1'/></config>")
+        archive.writestr(
+            "Metadata/slice_info.config",
+            "<config><plate><metadata key='index' value='1'/></plate></config>",
+        )
+    return buffer.getvalue()
+
+
+def _cloud_listing(*presets: UnifiedPreset, status: str = "ok"):
+    slots = {"printer": [], "process": list(presets), "filament": []}
+    return AsyncMock(return_value=(slots, status))
+
+
+async def _slice(request: SliceRequest, *, model: str = "stl", embedded_fallback: bool = False, cloud_listing=None):
+    from backend.app.services import preset_resolver, slicer_api as slicer_api_module
+
+    if cloud_listing is None:
+        cloud_listing = _cloud_listing(status="not_authenticated")
+
+    result = slicer_api_module.SliceResult(
+        content=_sliced_3mf(["", "", ""]),
+        print_time_seconds=600,
+        filament_used_g=12.0,
+        filament_used_mm=4000.0,
+    )
+    service = MagicMock()
+    service.close = AsyncMock()
+    if embedded_fallback:
+        service.slice_with_profiles = AsyncMock(side_effect=slicer_api_module.SlicerApiServerError("boom"))
+        service.slice_without_profiles = AsyncMock(return_value=result)
+    else:
+        service.slice_with_profiles = AsyncMock(return_value=result)
+        service.slice_without_profiles = AsyncMock()
+
+    async def _setting(_db, key):
+        return {"preferred_slicer": "bambu_studio", "bambu_studio_api_url": "http://sidecar:3000"}.get(key)
+
+    async def _resolve(_db, _user, ref, slot):
+        if ref.source == "standard":
+            return preset_resolver._resolve_standard(ref, slot)
+        # A cloud or local preset arrives flattened.
+        return json.dumps({"name": ref.id, "from": "system", "type": "process", "wall_loops": "3"})
+
+    with (
+        patch("backend.app.api.routes.settings.get_setting", new=AsyncMock(side_effect=_setting)),
+        patch("backend.app.services.preset_resolver.resolve_preset_ref", new=AsyncMock(side_effect=_resolve)),
+        patch.object(slicer_api_module, "SlicerApiService", return_value=service),
+        patch.object(slicer_api_module, "get_stall_timeout_seconds", new=AsyncMock(return_value=60.0)),
+        patch("backend.app.api.routes.slicer_presets._fetch_cloud_presets", new=cloud_listing),
+    ):
+        result, _ = await _run_slicer_with_fallback(
+            SimpleNamespace(get=AsyncMock(return_value=None)),
+            model_bytes=_source_3mf() if model == "3mf" else b"solid cube\nendsolid cube\n",
+            model_filename=f"cube.{model}",
+            request=request,
+            current_user_id=None,
+        )
+    return _config(result.content)["different_settings_to_system"]
+
+
+@pytest.mark.asyncio
+class TestTheSlice:
+    async def test_the_reporters_overrides_are_listed(self):
+        listed = await _slice(
+            _request(process_overrides={"sparse_infill_density": "40%", "sparse_infill_pattern": "gyroid"})
+        )
+
+        assert listed == ["sparse_infill_density;sparse_infill_pattern", "", ""]
+
+    async def test_the_build_plate_pick_is_not_listed(self):
+        listed = await _slice(_request(bed_type="Textured PEI Plate", process_overrides={"wall_loops": "3"}))
+
+        assert listed == ["wall_loops", "", ""]
+
+    async def test_the_designer_settings_picked_are_listed(self):
+        # support_type was offered and left unticked (#2942): not carried, not listed.
+        listed = await _slice(_request(design_overrides=["enable_support"]), model="3mf")
+
+        assert listed == ["enable_support", "", ""]
+
+    async def test_the_carried_support_settings_are_listed(self):
+        # A client without the per-key choice gets the #1881 support carry.
+        listed = await _slice(_request(), model="3mf")
+
+        assert listed == ["enable_support;support_type", "", ""]
+
+    async def test_no_overrides_lists_nothing(self):
+        assert await _slice(_request()) == ["", "", ""]
+
+    async def test_a_local_process_preset_lists_nothing(self):
+        cloud_listing = _cloud_listing(status="not_authenticated")
+        listed = await _slice(
+            _request(process_source="local", process_overrides={"sparse_infill_density": "40%"}),
+            cloud_listing=cloud_listing,
+        )
+
+        assert listed == ["", "", ""]
+        # Nor does it ask Bambu Cloud.
+        cloud_listing.assert_not_awaited()
+
+    async def test_bambus_stock_process_preset_on_bambu_cloud_lists_them(self):
+        # Signed in, the stock presets are picked from Bambu Cloud (#3386).
+        listed = await _slice(
+            _request(
+                process_source="cloud",
+                process_id="GP000",
+                process_overrides={"sparse_infill_density": "40%", "sparse_infill_pattern": "gyroid"},
+            ),
+            cloud_listing=_cloud_listing(
+                UnifiedPreset(id="GP000", name="0.20mm Standard @BBL A1M", source="cloud", stock=True)
+            ),
+        )
+
+        assert listed == ["sparse_infill_density;sparse_infill_pattern", "", ""]
+
+    async def test_a_private_bambu_cloud_process_preset_lists_nothing(self):
+        listed = await _slice(
+            _request(process_source="cloud", process_id="PPUS1", process_overrides={"sparse_infill_density": "40%"}),
+            cloud_listing=_cloud_listing(
+                UnifiedPreset(id="PPUS1", name="My 0.20mm", source="cloud", stock=False),
+                UnifiedPreset(id="GP000", name="0.20mm Standard @BBL A1M", source="cloud", stock=True),
+            ),
+        )
+
+        assert listed == ["", "", ""]
+
+    async def test_a_cloud_preset_without_the_listing_lists_nothing(self):
+        listed = await _slice(
+            _request(process_source="cloud", process_id="GP000", process_overrides={"sparse_infill_density": "40%"}),
+            cloud_listing=_cloud_listing(status="unreachable"),
+        )
+
+        assert listed == ["", "", ""]
+
+    async def test_the_embedded_settings_fallback_lists_nothing(self):
+        listed = await _slice(
+            _request(process_overrides={"sparse_infill_density": "40%"}), model="3mf", embedded_fallback=True
+        )
+
+        assert listed == ["", "", ""]

+ 218 - 0
backend/tests/unit/test_scheduler_wear_order_3480.py

@@ -0,0 +1,218 @@
+"""An "Any model" job goes to the eligible printer with the fewest print hours (#3480).
+
+The matcher used to take the first eligible printer in database order, so on a
+farm the lowest id got most of the work. It now ranks the printers of the model
+by the maintenance hour counter (runtime plus the baseline offset), fewest
+first, and the smart-plug wake step powers on the least worn one the same way.
+Everything that decides whether a printer is eligible is unchanged.
+"""
+
+from contextlib import ExitStack
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+import backend.app.models  # noqa: F401 - populate Base.metadata
+from backend.app.core.database import Base
+from backend.app.models.library import LibraryFile
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.printer import Printer
+from backend.app.services.print_scheduler import PrintScheduler, _ModelCandidate
+
+HOUR = 3600
+
+
+async def _db(printers: dict[int, tuple[int, float]]):
+    """X1Cs by id: (runtime_seconds, print_hours_offset)."""
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    session_maker = async_sessionmaker(engine, expire_on_commit=False)
+    async with session_maker() as db:
+        db.add_all(
+            [
+                Printer(
+                    id=pid,
+                    name=f"X1C-{pid}",
+                    serial_number=f"X1C000{pid}",
+                    ip_address=f"192.0.2.{pid}",
+                    access_code="x",
+                    model="X1C",
+                    is_active=True,
+                    runtime_seconds=runtime,
+                    print_hours_offset=offset,
+                )
+                for pid, (runtime, offset) in printers.items()
+            ]
+        )
+        await db.commit()
+    return engine, SimpleNamespace(session_maker=session_maker)
+
+
+@pytest.fixture
+async def farm():
+    """Printer 1 is the most worn, printer 3 the least; 2 sits between them."""
+    engine, ctx = await _db({1: (900 * HOUR, 0.0), 2: (100 * HOUR, 0.0), 3: (20 * HOUR, 0.0)})
+    try:
+        yield ctx
+    finally:
+        await engine.dispose()
+
+
+async def _find(ctx, scheduler, *, exclude=frozenset(), short=None, **kwargs):
+    async def is_short(pid):
+        return pid in short
+
+    with (
+        patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
+        patch.object(scheduler, "_is_printer_idle", MagicMock(return_value=True)),
+    ):
+        async with ctx.session_maker() as db:
+            return await scheduler._find_idle_printer_for_model(
+                db, "X1C", set(exclude), is_short=is_short if short is not None else None, **kwargs
+            )
+
+
+class TestMatcher:
+    @pytest.mark.asyncio
+    async def test_takes_the_printer_with_the_fewest_hours(self, farm):
+        assert await _find(farm, PrintScheduler()) == (3, None)
+
+    @pytest.mark.asyncio
+    async def test_a_busy_least_worn_printer_passes_the_job_to_the_next(self, farm):
+        assert await _find(farm, PrintScheduler(), exclude={3}) == (2, None)
+
+    @pytest.mark.asyncio
+    async def test_counts_the_baseline_offset_like_maintenance(self):
+        """A second-hand machine entered with its real hours is not "new"."""
+        engine, ctx = await _db({1: (10 * HOUR, 0.0), 2: (0, 500.0)})
+        try:
+            assert await _find(ctx, PrintScheduler()) == (1, None)
+        finally:
+            await engine.dispose()
+
+    @pytest.mark.asyncio
+    async def test_printers_still_at_zero_go_by_id(self):
+        engine, ctx = await _db({3: (0, 0.0), 1: (0, 0.0), 2: (0, 0.0)})
+        try:
+            assert await _find(ctx, PrintScheduler()) == (1, None)
+        finally:
+            await engine.dispose()
+
+    @pytest.mark.asyncio
+    async def test_a_short_printer_is_passed_over_for_the_next_least_worn(self, farm):
+        assert await _find(farm, PrintScheduler(), short={3}) == (2, None)
+
+    @pytest.mark.asyncio
+    async def test_every_printer_short_holds_the_job_on_the_least_worn(self, farm):
+        assert await _find(farm, PrintScheduler(), short={1, 2, 3}) == (3, None)
+
+    @pytest.mark.asyncio
+    async def test_more_preferred_colours_still_beat_fewer_hours(self, farm):
+        scheduler = PrintScheduler()
+        matches = {1: 2, 2: 1, 3: 1}
+        with patch.object(scheduler, "_count_override_color_matches", side_effect=lambda pid, _o: matches[pid]):
+            result = await _find(farm, scheduler, filament_overrides=[{"type": "PLA", "color": "#000000"}])
+        assert result == (1, None)
+
+    @pytest.mark.asyncio
+    async def test_equal_preferred_colours_go_to_the_fewest_hours(self, farm):
+        scheduler = PrintScheduler()
+        matches = {1: 1, 2: 2, 3: 2}
+        with patch.object(scheduler, "_count_override_color_matches", side_effect=lambda pid, _o: matches[pid]):
+            result = await _find(farm, scheduler, filament_overrides=[{"type": "PLA", "color": "#000000"}])
+        assert result == (3, None)
+
+
+class TestQueuePass:
+    @pytest.mark.asyncio
+    async def test_jobs_spread_over_the_least_worn_printers(self, farm):
+        """Two "any X1C" jobs, three idle X1Cs: the two least worn take them."""
+        ids = []
+        async with farm.session_maker() as db:
+            for pos in (1, 2):
+                lib = LibraryFile(
+                    filename=f"job{pos}.gcode.3mf",
+                    file_path=f"/library/job{pos}.gcode.3mf",
+                    file_size=10,
+                    file_type="gcode.3mf",
+                    file_metadata={"sliced_for_model": "X1C"},
+                )
+                db.add(lib)
+                await db.flush()
+                item = PrintQueueItem(status="pending", position=pos, target_model="X1C", library_file_id=lib.id)
+                db.add(item)
+                await db.flush()
+                ids.append(item.id)
+            await db.commit()
+
+        scheduler = PrintScheduler()
+        patches = [
+            patch("backend.app.services.print_scheduler.async_session", farm.session_maker),
+            patch("backend.app.core.database.async_session", farm.session_maker),
+            patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
+            patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
+            patch(
+                "backend.app.services.print_scheduler.ha_sensor_manager.blocked_printers",
+                AsyncMock(return_value={}),
+            ),
+            patch(
+                "backend.app.services.notification_service.notification_service.on_queue_job_waiting",
+                AsyncMock(),
+            ),
+            patch(
+                "backend.app.services.notification_service.notification_service.on_queue_job_assigned",
+                AsyncMock(),
+            ),
+            patch("backend.app.services.print_scheduler.compute_deficit_for_queue_item", AsyncMock(return_value=[])),
+            patch.object(scheduler, "_filament_short_on", AsyncMock(return_value=False)),
+            patch.object(scheduler, "_is_printer_idle", MagicMock(return_value=True)),
+            patch.object(scheduler, "_check_auto_drying", AsyncMock()),
+            patch.object(scheduler, "_ensure_ams_mapping", AsyncMock(return_value=None)),
+            patch.object(scheduler, "_block_on_unmatched_filament", AsyncMock(return_value=False)),
+            patch.object(scheduler, "_launch_uploads", MagicMock()),
+        ]
+        with ExitStack() as stack:
+            for p in patches:
+                stack.enter_context(p)
+            await scheduler.check_queue()
+
+        async with farm.session_maker() as db:
+            rows = (await db.execute(select(PrintQueueItem).where(PrintQueueItem.id.in_(ids)))).scalars().all()
+        assert sorted(row.printer_id for row in rows) == [2, 3]
+
+
+class TestWake:
+    @pytest.mark.asyncio
+    async def test_powers_on_the_least_worn_offline_printer(self, farm):
+        scheduler = PrintScheduler()
+        plug = SimpleNamespace(id=1, auto_on=True, enabled=True, controls_printer_power=True)
+        power_on = AsyncMock(return_value=True)
+        with (
+            patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=False)),
+            patch(
+                "backend.app.services.print_scheduler.printer_manager.is_awaiting_plate_clear",
+                MagicMock(return_value=False),
+            ),
+            patch.object(scheduler, "_cached_filament_shortfall", MagicMock(return_value=[])),
+            patch.object(scheduler, "_get_smart_plugs", AsyncMock(return_value=[plug])),
+            patch.object(scheduler, "_power_on_and_wait", power_on),
+        ):
+            async with farm.session_maker() as db:
+                woken = await scheduler._wake_printer_for_model(
+                    db,
+                    [
+                        _ModelCandidate(
+                            target_model="X1C", sliced_for="X1C", required_filament_types=None, filament_overrides=None
+                        )
+                    ],
+                    None,
+                    set(),
+                    {1, 2, 3},
+                    require_plate_clear=True,
+                )
+        assert woken == (3, 3)
+        assert power_on.call_args[0][1] == 3

+ 195 - 0
backend/tests/unit/test_spoolman_slot_rack_ams_migration.py

@@ -0,0 +1,195 @@
+"""spoolman_slot_assignments admits the H3C's AMS 3H: ams_id 32 with slots 0-8, externals (255, 2-8).
+
+Slots 4-8 exist only on the AMS 3H and among an H3C's external inputs, so the
+tray CHECK is ``tray_id >= 0 AND (tray_id <= 3 OR (ams_id IN (32, 255) AND
+tray_id <= 8))``. An upgraded SQLite table is rebuilt to it from upstream's
+CHECKs and from the plain ``tray_id <= 8`` an earlier build of this support
+wrote; a row the latter let through on another unit is dropped.
+"""
+
+from __future__ import annotations
+
+import pytest
+from sqlalchemy import text
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.ext.asyncio import create_async_engine
+
+from backend.app.core.database import _migrate_widen_spoolman_slot_ams_id_range
+from backend.app.models.spoolman_slot_assignment import AMS_ID_CHECK, TRAY_ID_CHECK
+
+_TABLE = """
+CREATE TABLE spoolman_slot_assignments (
+    id INTEGER NOT NULL,
+    printer_id INTEGER NOT NULL,
+    ams_id INTEGER NOT NULL,
+    tray_id INTEGER NOT NULL,
+    spoolman_spool_id INTEGER NOT NULL,
+    assigned_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
+    PRIMARY KEY (id),
+    CONSTRAINT uq_slot_assignment UNIQUE (printer_id, ams_id, tray_id),
+    CONSTRAINT ck_ams_id_range CHECK ({ams}),
+    CONSTRAINT ck_tray_id_range CHECK ({tray})
+)
+"""
+_AMS_1274 = "(ams_id >= 0 AND ams_id <= 7) OR (ams_id >= 128 AND ams_id <= 191) OR ams_id = 255"
+# The table as create_all built it for the #1274 model (upstream dev).
+TABLE_1274 = _TABLE.format(ams=_AMS_1274, tray="tray_id >= 0 AND tray_id <= 3")
+# As an earlier build of the H3C support left it: by its own SQLite rebuild...
+TABLE_WIDENED_REBUILT = (
+    "CREATE TABLE spoolman_slot_assignments (id INTEGER PRIMARY KEY AUTOINCREMENT, "
+    "printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE, "
+    f"ams_id INTEGER NOT NULL CHECK ({AMS_ID_CHECK}), "
+    "tray_id INTEGER NOT NULL CHECK (tray_id >= 0 AND tray_id <= 8), "
+    "spoolman_spool_id INTEGER NOT NULL, assigned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "
+    "CONSTRAINT uq_slot_assignment UNIQUE(printer_id, ams_id, tray_id))"
+)
+# ...and by the live-install script, which rewrote the create_all form in place.
+TABLE_WIDENED_IN_PLACE = _TABLE.format(ams=AMS_ID_CHECK, tray="tray_id >= 0 AND tray_id <= 8")
+TABLE_FINAL = _TABLE.format(ams=AMS_ID_CHECK, tray=TRAY_ID_CHECK)
+
+
+@pytest.fixture(autouse=True)
+def force_sqlite_dialect(monkeypatch):
+    from backend.app.core import database as database_module, db_dialect
+
+    monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
+    monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
+    monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
+
+
+async def _insert(conn, ams_id: int, tray_id: int, spool: int) -> None:
+    await conn.execute(
+        text(
+            "INSERT INTO spoolman_slot_assignments (printer_id, ams_id, tray_id, spoolman_spool_id) "
+            "VALUES (1, :a, :t, :s)"
+        ),
+        {"a": ams_id, "t": tray_id, "s": spool},
+    )
+
+
+async def _rows(conn) -> list[tuple]:
+    rows = await conn.execute(
+        text("SELECT ams_id, tray_id, spoolman_spool_id FROM spoolman_slot_assignments ORDER BY id")
+    )
+    return [tuple(r) for r in rows.fetchall()]
+
+
+async def _assert_final_checks(conn) -> None:
+    await _insert(conn, 32, 8, 14)  # AMS 3H slot 9
+    await _insert(conn, 255, 5, 15)  # an H3C external input (250)
+    for ams_id, tray_id in ((0, 4), (128, 5), (8, 0), (32, 9), (40, 0), (255, 9)):
+        with pytest.raises(IntegrityError):
+            async with conn.begin_nested():
+                await _insert(conn, ams_id, tray_id, 99)
+    indexes = (
+        await conn.execute(
+            text("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='spoolman_slot_assignments'")
+        )
+    ).fetchall()
+    assert "ix_slot_assignment_spool" in {r[0] for r in indexes}
+
+
+async def _table(conn, ddl: str) -> None:
+    await conn.execute(text(ddl))
+    await conn.execute(text("CREATE INDEX ix_slot_assignment_spool ON spoolman_slot_assignments (spoolman_spool_id)"))
+
+
+@pytest.mark.asyncio
+async def test_upstreams_table_takes_the_ams3h_and_keeps_its_rows():
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:")
+    async with engine.begin() as conn:
+        await _table(conn, TABLE_1274)
+        await _insert(conn, 0, 3, 11)
+        await _insert(conn, 128, 0, 12)
+        await _insert(conn, 255, 1, 13)
+        with pytest.raises(IntegrityError):
+            async with conn.begin_nested():
+                await _insert(conn, 32, 8, 14)
+
+        await _migrate_widen_spoolman_slot_ams_id_range(conn)
+        await _migrate_widen_spoolman_slot_ams_id_range(conn)  # a second start changes nothing
+
+        assert await _rows(conn) == [(0, 3, 11), (128, 0, 12), (255, 1, 13)]
+        await _assert_final_checks(conn)
+    await engine.dispose()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("ddl", [TABLE_WIDENED_REBUILT, TABLE_WIDENED_IN_PLACE])
+async def test_a_table_an_earlier_build_widened_is_narrowed_to_real_slots(ddl):
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:")
+    async with engine.begin() as conn:
+        await _table(conn, ddl)
+        await _insert(conn, 32, 6, 11)
+        await _insert(conn, 255, 4, 12)
+        await _insert(conn, 0, 6, 13)  # slot 7 of a regular AMS: no such slot
+
+        await _migrate_widen_spoolman_slot_ams_id_range(conn)
+        await _migrate_widen_spoolman_slot_ams_id_range(conn)
+
+        assert await _rows(conn) == [(32, 6, 11), (255, 4, 12)]
+        await _assert_final_checks(conn)
+    await engine.dispose()
+
+
+@pytest.mark.asyncio
+async def test_a_final_table_is_left_alone():
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:")
+    async with engine.begin() as conn:
+        await _table(conn, TABLE_FINAL)
+        await _insert(conn, 32, 6, 11)
+        before = (
+            await conn.execute(text("SELECT sql FROM sqlite_master WHERE name='spoolman_slot_assignments'"))
+        ).scalar()
+
+        await _migrate_widen_spoolman_slot_ams_id_range(conn)
+
+        after = (
+            await conn.execute(text("SELECT sql FROM sqlite_master WHERE name='spoolman_slot_assignments'"))
+        ).scalar()
+        assert after == before
+        assert await _rows(conn) == [(32, 6, 11)]
+        await _assert_final_checks(conn)
+    await engine.dispose()
+
+
+def test_the_model_admits_what_the_migration_does():
+    from sqlalchemy import CheckConstraint
+
+    from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+
+    checks = {
+        c.name: str(c.sqltext) for c in SpoolmanSlotAssignment.__table__.constraints if isinstance(c, CheckConstraint)
+    }
+    assert checks["ck_ams_id_range"] == AMS_ID_CHECK
+    assert (
+        checks["ck_tray_id_range"]
+        == TRAY_ID_CHECK
+        == ("tray_id >= 0 AND (tray_id <= 3 OR (ams_id IN (32, 255) AND tray_id <= 8))")
+    )
+
+
+def test_the_fresh_install_ddl_matches_the_model():
+    import inspect
+
+    import backend.app.core.database as database_module
+
+    source = inspect.getsource(database_module)
+    for dialect_marker in ("id INTEGER PRIMARY KEY AUTOINCREMENT,\n            printer_id", "id SERIAL PRIMARY KEY,"):
+        start = source.index("CREATE TABLE IF NOT EXISTS spoolman_slot_assignments (\n            " + dialect_marker)
+        ddl = source[start : source.index(')\n        """', start)]
+        assert f"CHECK ({AMS_ID_CHECK})" in ddl and f"CHECK ({TRAY_ID_CHECK})" in ddl
+
+
+def test_the_request_schema_admits_the_ams3h():
+    from pydantic import TypeAdapter, ValidationError
+
+    from backend.app.schemas.spoolman import SlotAmsId, SlotTrayId
+
+    assert TypeAdapter(SlotAmsId).validate_python(32) == 32
+    assert TypeAdapter(SlotTrayId).validate_python(8) == 8
+    for bad_ams in (8, 31, 33):
+        with pytest.raises(ValidationError):
+            TypeAdapter(SlotAmsId).validate_python(bad_ams)
+    with pytest.raises(ValidationError):
+        TypeAdapter(SlotTrayId).validate_python(9)

+ 36 - 0
backend/tests/unit/test_support_model_name_redaction_3442.py

@@ -0,0 +1,36 @@
+"""A printer named after its model keeps the model in the support package (#3442).
+
+The support package redacts printer names everywhere. A printer named "H3C"
+turned every "H3C" into [PRINTER], so the package no longer said which model
+it came from. A name that is only a model name is not private and stays.
+"""
+
+import pytest
+
+from backend.app.services.log_reader import collect_sensitive_strings, sanitize_log_content
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    ("name", "model"),
+    [
+        ("H3C", "H3C"),  # a model Bambuddy does not list yet: the row's own model decides
+        ("x1c", "P1S"),  # a listed display name, any case
+        ("A1mini", "A1"),  # spaces do not matter
+        ("BL-P001", "X1C"),  # an internal code
+        ("Bambu Lab H2D", "H2D"),  # the 3MF name
+    ],
+)
+async def test_a_model_name_is_not_redacted(db_session, printer_factory, name, model):
+    await printer_factory(name=name, model=model)
+    sensitive = await collect_sensitive_strings(db_session)
+    assert name not in sensitive
+
+
+@pytest.mark.asyncio
+async def test_a_real_name_is_still_redacted(db_session, printer_factory):
+    await printer_factory(name="Workshop H3C", model="H3C")
+    sensitive = await collect_sensitive_strings(db_session)
+    assert sensitive["Workshop H3C"] == "[PRINTER]"
+    line = "Workshop H3C: model H3C connected"
+    assert sanitize_log_content(line, sensitive) == "[PRINTER]: model H3C connected"

+ 291 - 0
backend/tests/unit/test_tray_ids.py

@@ -0,0 +1,291 @@
+"""Bambu Studio's global tray numbering (utils.tray_ids), for every AMS kind.
+
+Studio (DevFilaSystem::GetTrayIndexMap, DevAmsTray::get_nozzle_addr_id):
+regular AMS ams_id*4+slot, A2L AMS Lite 24+slot, AMS 3H 28+slot, AMS-B
+37+slot, AMS-HT its unit id, external inputs their own vir_slot id -- 254/255,
+and 247-255 on a printer with a direct-connected nozzle rack, where input v
+feeds hotend 255-v.
+"""
+
+import pytest
+
+from backend.app.utils.tray_ids import (
+    AmsType,
+    TrayLayout,
+    ams_type_from_info,
+    decode_wire_slot,
+    external_hotend,
+    external_slot_index,
+    external_tray_id,
+    global_tray_id,
+    is_external_tray,
+    is_rack_ams,
+    legacy_tray_index,
+    mapped_trays_with_slot,
+    tray_flag_bit,
+    unit_ams_type,
+    unit_label,
+)
+
+# The H3C's AMS 3H as the printer reports it (info captured 2026-11-06).
+H3C_UNITS = [{"id": "32", "info": "240842026", "tray": [{"id": str(k)} for k in range(9)]}]
+H3C_VT = [{"id": str(v)} for v in range(247, 256)]
+H3C = TrayLayout.from_units(H3C_UNITS, H3C_VT)
+
+
+class TestAmsType:
+    @pytest.mark.parametrize(
+        ("info", "kind"),
+        [
+            ("10001001", AmsType.AMS),
+            ("2002", AmsType.AMS_LITE),
+            ("10003", AmsType.N3F),
+            ("1104", AmsType.N3S),
+            ("5", AmsType.AMS_LITE_MIXED),
+            ("240842026", AmsType.ZMULTI_A),
+            ("240002006", AmsType.ZMULTI_A),
+            ("7", AmsType.ZMULTI_B),
+        ],
+    )
+    def test_low_four_bits_of_the_hex_info(self, info, kind):
+        assert ams_type_from_info(info) == kind
+
+    @pytest.mark.parametrize("info", [None, "", "zz", True, 3.5])
+    def test_unreadable_info_is_none(self, info):
+        assert ams_type_from_info(info) is None
+
+    def test_type_told_from_the_id_without_info(self):
+        assert unit_ams_type(0) == AmsType.AMS
+        assert unit_ams_type(3) == AmsType.AMS
+        assert unit_ams_type(128) == AmsType.N3S
+        assert unit_ams_type(135) == AmsType.N3S
+        assert unit_ams_type(32) == AmsType.ZMULTI_A
+
+    def test_info_outranks_the_id_except_for_an_ams_ht(self):
+        assert unit_ams_type(32, "7") == AmsType.ZMULTI_B
+        assert unit_ams_type(128, "1") == AmsType.N3S
+
+    def test_rack_types(self):
+        assert is_rack_ams(AmsType.ZMULTI_A) and is_rack_ams(AmsType.ZMULTI_B)
+        assert not any(is_rack_ams(k) for k in (AmsType.AMS, AmsType.N3F, AmsType.N3S, AmsType.AMS_LITE_MIXED))
+
+
+class TestGlobalTrayId:
+    @pytest.mark.parametrize(
+        ("ams_id", "slot", "kind", "expected"),
+        [
+            (0, 0, AmsType.AMS, 0),
+            (1, 2, AmsType.AMS, 6),
+            (3, 3, AmsType.N3F, 15),
+            (6, 1, AmsType.AMS_LITE, 25),  # the A2L's AMS Lite, stored as unit 6
+            (16, 1, AmsType.AMS_LITE_MIXED, 25),  # ...and Studio's own numbering of its unit 16
+            (32, 0, AmsType.ZMULTI_A, 28),
+            (32, 8, AmsType.ZMULTI_A, 36),
+            (33, 0, AmsType.ZMULTI_B, 37),
+            (33, 4, AmsType.ZMULTI_B, 41),
+            (128, 0, AmsType.N3S, 128),
+            (135, 0, AmsType.N3S, 135),
+        ],
+    )
+    def test_every_ams_kind(self, ams_id, slot, kind, expected):
+        assert global_tray_id(ams_id, slot, kind) == expected
+
+    def test_without_a_type_regular_ht_and_the_ams3h_by_their_ids(self):
+        assert [global_tray_id(1, s) for s in range(4)] == [4, 5, 6, 7]
+        assert global_tray_id(129, 0) == 129
+        assert [global_tray_id(32, s) for s in range(9)] == list(range(28, 37))
+
+    def test_the_ams3h_no_longer_lands_in_the_ams_ht_range(self):
+        ams3h = {global_tray_id(32, s) for s in range(9)}
+        assert ams3h.isdisjoint(range(128, 136))
+        assert ams3h.isdisjoint(range(0, 16))
+
+
+class TestLayout:
+    def test_h3c_is_a_rack_printer(self):
+        assert H3C.rack
+        assert H3C.rack_units == ((32, AmsType.ZMULTI_A),)
+        assert H3C.ams_type(32) == AmsType.ZMULTI_A
+
+    def test_both_directions_on_the_h3c(self):
+        for slot in range(9):
+            tray = H3C.global_tray_id(32, slot)
+            assert tray == 28 + slot
+            assert H3C.slot_key(tray) == (32, slot)
+            assert H3C.tray_from_slot_key(32, slot) == tray
+
+    def test_both_directions_on_a_regular_printer(self):
+        layout = TrayLayout.from_units([{"id": "0", "info": "1001"}, {"id": "1", "info": "1001"}, {"id": "128"}])
+        assert not layout.rack
+        for ams_id in (0, 1):
+            for slot in range(4):
+                tray = layout.global_tray_id(ams_id, slot)
+                assert tray == ams_id * 4 + slot
+                assert layout.slot_key(tray) == (ams_id, slot)
+                assert layout.tray_from_slot_key(ams_id, slot) == tray
+        assert layout.global_tray_id(128, 0) == 128
+        assert layout.slot_key(128) == (128, 0)
+
+    def test_without_live_data_both_directions_agree(self):
+        # No AMS data at all: 28-36 are the AMS 3H's (unit 32) and 247-253 the
+        # per-hotend inputs, the same way round as tray_from_slot_key numbers them.
+        empty = TrayLayout()
+        for slot in range(9):
+            assert empty.tray_from_slot_key(32, slot) == 28 + slot
+            assert empty.slot_key(28 + slot) == (32, slot)
+        for index in range(9):
+            assert empty.slot_key(empty.tray_from_slot_key(255, index)) == (255, index)
+
+    def test_a_listed_regular_unit_7_keeps_28_to_31(self):
+        layout = TrayLayout.from_units([{"id": "7", "info": "1001"}])
+        assert layout.slot_key(28) == (7, 0)
+        assert layout.slot_key(31) == (7, 3)
+        assert layout.tray_from_slot_key(7, 2) == 30
+
+    def test_ams_b_by_its_info(self):
+        layout = TrayLayout.from_units([{"id": "40", "info": "7"}])
+        assert layout.rack
+        assert layout.global_tray_id(40, 2) == 39
+        assert layout.slot_key(39) == (40, 2)
+
+    def test_api_units_with_an_explicit_type(self):
+        layout = TrayLayout.from_units([{"id": 32, "ams_type": 6}])
+        assert layout.global_tray_id(32, 4) == 32
+        assert layout.slot_key(32) == (32, 4)
+
+    def test_raw_tray_now_of_the_rack(self):
+        assert [H3C.raw_slot_to_global(k) for k in range(9)] == list(range(28, 37))
+        assert H3C.raw_slot_to_global(255) is None
+        assert TrayLayout.from_units([{"id": "0"}]).raw_slot_to_global(2) is None
+
+    def test_loaded_trays(self):
+        regular = TrayLayout.from_units([{"id": "0"}])
+        for tray in (0, 15, 24, 27, 128, 135, 254):
+            assert regular.is_loaded_tray(tray)
+        for tray in (16, 28, 250, 255):
+            assert not regular.is_loaded_tray(tray)
+        assert H3C.is_loaded_tray(28) and H3C.is_loaded_tray(36) and H3C.is_loaded_tray(247)
+        assert not H3C.is_loaded_tray(255)
+
+    def test_layout_from_a_state(self):
+        class _State:
+            raw_data = {"ams": H3C_UNITS, "vt_tray": H3C_VT}
+
+        assert TrayLayout.from_state(_State()) == H3C
+        assert TrayLayout.from_state(None) == TrayLayout()
+
+    def test_an_ams3h_model_without_ams_data_gets_its_ams3h(self):
+        class _NoAms:
+            raw_data: dict = {}
+
+        assert TrayLayout.from_state(None, ams3h_model=True) == TrayLayout.ams3h()
+        assert TrayLayout.from_state(_NoAms(), ams3h_model=True).slot_key(250) == (255, 5)
+        assert TrayLayout.ams3h().is_external(250)
+
+        # Live units always win over the model.
+        class _Regular:
+            raw_data = {"ams": [{"id": "0", "info": "1001"}]}
+
+        assert TrayLayout.from_state(_Regular(), ams3h_model=True).rack_units == ()
+
+
+class TestWireSlot:
+    """(ams_id << 8) | slot, as print.mapping and the snow fields carry a slot."""
+
+    def test_regular_ams_ht_and_external(self):
+        assert decode_wire_slot((1 << 8) | 2) == 6
+        assert decode_wire_slot((0 << 8) | 0x13) == 3  # slot bits & 3
+        assert decode_wire_slot(129 << 8) == 129
+        assert decode_wire_slot((254 << 8) | 0) == 254
+        assert decode_wire_slot((255 << 8) | 0) == 254
+        assert decode_wire_slot((254 << 8) | 255) == 255
+        assert decode_wire_slot(0xFFFF) is None
+        assert decode_wire_slot(-1) is None
+        assert decode_wire_slot((16 << 8) | 1) is None
+        assert decode_wire_slot("258") == 6
+
+    def test_ams3h_slots_with_and_without_a_layout(self):
+        assert [decode_wire_slot((32 << 8) | k, H3C) for k in range(9)] == list(range(28, 37))
+        assert decode_wire_slot((32 << 8) | 6) == 34
+        assert decode_wire_slot((32 << 8) | 9, H3C) is None
+
+    def test_a_rack_printer_keeps_its_external_inputs(self):
+        # On a rack printer every input 247-255 is its own hotend's.
+        assert decode_wire_slot((250 << 8) | 0, H3C) == 250
+        assert decode_wire_slot((255 << 8) | 0, H3C) == 255
+        assert decode_wire_slot((254 << 8) | 0, H3C) == 254
+        assert decode_wire_slot((250 << 8) | 0) is None
+
+    def test_trays_with_a_local_slot(self):
+        mapping = [(0 << 8) | 1, (1 << 8) | 1, (1 << 8) | 2, 128 << 8, 254 << 8, 0xFFFF]
+        assert mapped_trays_with_slot(mapping, 1) == {1: 0, 5: 1}
+        assert mapped_trays_with_slot(mapping, 0) == {128: 128}
+        assert mapped_trays_with_slot(None, 0) == {}
+
+    def test_legacy_index_is_the_flat_four_per_unit_number(self):
+        assert legacy_tray_index(1, 2) == 6
+        assert legacy_tray_index(16, 3) == 67  # the A2L AMS Lite's physical tray
+        assert legacy_tray_index(255, 1) == 1021
+
+
+class TestExternals:
+    def test_254_255_are_external_everywhere(self):
+        for tray in (254, 255):
+            assert is_external_tray(tray)
+            assert is_external_tray(tray, rack=True)
+
+    def test_247_to_253_only_on_a_rack_printer(self):
+        for tray in range(247, 254):
+            assert not is_external_tray(tray)
+            assert is_external_tray(tray, rack=True)
+            assert H3C.is_external(tray)
+        assert not TrayLayout.from_units([{"id": "0"}], [{"id": "254"}, {"id": "255"}]).is_external(250)
+
+    def test_ams_slots_are_not_external(self):
+        for tray in (0, 15, 28, 36, 128):
+            assert not is_external_tray(tray, rack=True)
+
+    def test_an_input_feeds_hotend_255_minus_its_id(self):
+        assert [external_hotend(v) for v in range(255, 246, -1)] == list(range(9))
+
+    def test_stored_under_ams_255(self):
+        assert external_slot_index(254) == 0 and external_slot_index(255) == 1
+        assert [external_slot_index(v) for v in range(247, 254)] == list(range(2, 9))
+        for v in range(247, 256):
+            assert external_tray_id(external_slot_index(v)) == v
+            assert H3C.slot_key(v) == (255, external_slot_index(v))
+            assert H3C.tray_from_slot_key(255, external_slot_index(v)) == v
+
+    def test_a_reported_input_247_to_253_makes_a_rack_printer(self):
+        assert TrayLayout.from_units([], H3C_VT).rack
+        assert not TrayLayout.from_units([], [{"id": "254"}, {"id": "255"}]).rack
+
+    def test_a_regular_printer_keeps_its_external_slot_keys(self):
+        layout = TrayLayout.from_units([{"id": "0"}], [{"id": "254"}])
+        assert layout.slot_key(254) == (255, 0)
+        assert layout.slot_key(255) == (255, 1)
+        # 247-253 are only ever external inputs, so they keep their stored key everywhere.
+        assert layout.slot_key(250) == (255, 5)
+        assert not layout.is_external(250)
+
+
+class TestTrayBitsAndLabels:
+    def test_tray_flag_bits(self):
+        assert tray_flag_bit(1, 2) == 6
+        assert tray_flag_bit(128, 0) == 16
+        assert tray_flag_bit(130, 0) == 18
+        assert tray_flag_bit(6, 1, AmsType.AMS_LITE_MIXED) == 25
+        assert [tray_flag_bit(32, k, AmsType.ZMULTI_A) for k in range(9)] == list(range(28, 37))
+        assert tray_flag_bit(40, 0, AmsType.AMS) is None
+
+    def test_unit_labels(self):
+        assert unit_label(0) == "AMS-A"
+        assert unit_label(129) == "HT-B"
+        assert unit_label(32) == "AMS 3H"
+
+
+def test_a_unit_listed_twice_counts_once():
+    # A push's units are combined with the ones already known: unit 32 twice is one AMS 3H.
+    layout = TrayLayout.from_units([{"id": "32", "info": "240002006"}, {"id": "32", "info": "240002006"}])
+    assert layout.rack_units == ((32, AmsType.ZMULTI_A),)
+    assert layout.raw_slot_to_global(4) == 32

+ 116 - 0
backend/tests/unit/test_windows_backup_paths_3501.py

@@ -0,0 +1,116 @@
+"""A backup from a Windows install restored on Linux finds its files (#3501).
+
+Windows stores paths relative to the data folder with backslashes; Linux reads
+them as part of one file name. The restore turns them into slashes.
+"""
+
+import pytest
+from sqlalchemy import select
+
+from backend.app.models.archive import PrintArchive
+from backend.app.models.library import LibraryFile
+from backend.app.models.print_log import PrintLogEntry
+from backend.app.services.backup_paths import convert_windows_paths
+
+
+def _windows_rows():
+    archive = PrintArchive(
+        filename="Brachy.3mf",
+        file_path="archive\\12\\Brachy.3mf",
+        file_size=1,
+        thumbnail_path="archive\\12\\thumbnail.png",
+        timelapse_path="archive\\12\\timelapse.mp4",
+        source_3mf_path="archive\\12\\source\\Brachy.3mf",
+        f3d_path="archive\\12\\design.f3d",
+    )
+    library_file = LibraryFile(
+        filename="Brachy.3mf",
+        file_path="archive\\library\\files\\abc.3mf",
+        file_type="3mf",
+        file_size=1,
+        thumbnail_path="archive\\library\\thumbnails\\abc.png",
+    )
+    log_entry = PrintLogEntry(
+        status="completed",
+        thumbnail_path="archive\\12\\thumbnail.png",
+        timelapse_path="archive\\12\\timelapse.mp4",
+    )
+    return archive, library_file, log_entry
+
+
+async def _convert(db_session, base_dir, **kwargs) -> int:
+    await db_session.flush()
+    changed = await convert_windows_paths(await db_session.connection(), base_dir, **kwargs)
+    db_session.expire_all()
+    return changed
+
+
+@pytest.mark.asyncio
+async def test_windows_paths_get_slashes(db_session, tmp_path):
+    db_session.add_all(_windows_rows())
+
+    assert await _convert(db_session, tmp_path, windows=False) == 3
+
+    archive = (await db_session.execute(select(PrintArchive))).scalar_one()
+    assert (
+        archive.file_path,
+        archive.thumbnail_path,
+        archive.timelapse_path,
+        archive.source_3mf_path,
+        archive.f3d_path,
+    ) == (
+        "archive/12/Brachy.3mf",
+        "archive/12/thumbnail.png",
+        "archive/12/timelapse.mp4",
+        "archive/12/source/Brachy.3mf",
+        "archive/12/design.f3d",
+    )
+    library_file = (await db_session.execute(select(LibraryFile))).scalar_one()
+    assert (library_file.file_path, library_file.thumbnail_path) == (
+        "archive/library/files/abc.3mf",
+        "archive/library/thumbnails/abc.png",
+    )
+    log_entry = (await db_session.execute(select(PrintLogEntry))).scalar_one()
+    assert (log_entry.thumbnail_path, log_entry.timelapse_path) == (
+        "archive/12/thumbnail.png",
+        "archive/12/timelapse.mp4",
+    )
+
+
+@pytest.mark.asyncio
+async def test_a_file_whose_name_has_a_backslash_is_kept(db_session, tmp_path):
+    # On Linux a backslash can be part of a real file name.
+    (tmp_path / "archive" / "7").mkdir(parents=True)
+    (tmp_path / "archive" / "7" / "a\\b.3mf").write_bytes(b"x")
+    db_session.add(PrintArchive(filename="a\\b.3mf", file_path="archive/7/a\\b.3mf", file_size=1))
+
+    assert await _convert(db_session, tmp_path, windows=False) == 0
+
+    archive = (await db_session.execute(select(PrintArchive))).scalar_one()
+    assert archive.file_path == "archive/7/a\\b.3mf"
+
+
+@pytest.mark.asyncio
+async def test_nothing_changes_on_windows(db_session, tmp_path):
+    db_session.add_all(_windows_rows())
+
+    assert await _convert(db_session, tmp_path, windows=True) == 0
+
+    archive = (await db_session.execute(select(PrintArchive))).scalar_one()
+    assert archive.file_path == "archive\\12\\Brachy.3mf"
+
+
+@pytest.mark.asyncio
+async def test_linux_paths_and_empty_columns_are_left_alone(db_session, tmp_path):
+    db_session.add(PrintArchive(filename="x.3mf", file_path="archive/3/x.3mf", file_size=1, thumbnail_path=None))
+    db_session.add(PrintLogEntry(status="completed"))
+
+    assert await _convert(db_session, tmp_path, windows=False) == 0
+
+
+@pytest.mark.asyncio
+async def test_a_second_run_changes_nothing(db_session, tmp_path):
+    db_session.add_all(_windows_rows())
+    await _convert(db_session, tmp_path, windows=False)
+
+    assert await _convert(db_session, tmp_path, windows=False) == 0

+ 30 - 0
backend/tests/unit/test_wled_config_migration.py

@@ -0,0 +1,30 @@
+"""Migration coverage for per-printer WLED configuration (#1528)."""
+
+import pytest
+from sqlalchemy import inspect, text
+from sqlalchemy.ext.asyncio import async_sessionmaker
+
+from backend.app.core.database import run_migrations
+from backend.app.models.printer import Printer
+
+
+@pytest.mark.asyncio
+async def test_wled_config_migration_is_additive_and_idempotent(test_engine):
+    session_factory = async_sessionmaker(test_engine, expire_on_commit=False)
+    async with session_factory() as db:
+        db.add(Printer(name="Legacy", serial_number="WLED-LEGACY", ip_address="10.0.0.1", access_code="code"))
+        await db.commit()
+
+    async with test_engine.begin() as conn:
+        await conn.execute(text("ALTER TABLE printers DROP COLUMN wled_config"))
+        await run_migrations(conn)
+        await run_migrations(conn)
+        columns = await conn.run_sync(
+            lambda sync_conn: {col["name"] for col in inspect(sync_conn).get_columns("printers")}
+        )
+        stored = (
+            await conn.execute(text("SELECT wled_config FROM printers WHERE serial_number = 'WLED-LEGACY'"))
+        ).scalar_one()
+
+    assert "wled_config" in columns
+    assert stored is None

+ 48 - 0
backend/tests/unit/test_wled_startup.py

@@ -0,0 +1,48 @@
+"""Startup WLED initialization keeps inactive printers offline."""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.main import initialize_wled_printers
+from backend.app.services.wled import WLEDManager
+
+
+@pytest.mark.asyncio
+async def test_startup_sets_only_inactive_printers_offline():
+    db = MagicMock()
+    db.execute = AsyncMock(return_value=MagicMock())
+    inactive_config = {"enabled": True, "base_url": "http://wled.local", "presets": {"offline": 9}}
+    active_config = {"enabled": True, "base_url": "http://wled.local", "presets": {"offline": 10}}
+    db.execute.return_value.all.return_value = [
+        (1, inactive_config, False),
+        (2, active_config, True),
+    ]
+    with (
+        patch("backend.app.api.routes.settings.get_setting", new=AsyncMock(return_value=False)),
+        patch("backend.app.main.wled_manager") as manager,
+    ):
+        await initialize_wled_printers(db)
+    assert manager.configure_printer.call_count == 2
+    manager.configure_printer.assert_any_call(1, inactive_config)
+    manager.configure_printer.assert_any_call(2, active_config)
+    manager.handle_offline.assert_called_once_with(1)
+
+
+@pytest.mark.asyncio
+async def test_startup_skips_missing_disabled_and_unmapped_wled_configs():
+    db = MagicMock()
+    db.execute = AsyncMock(return_value=MagicMock())
+    db.execute.return_value.all.return_value = [
+        (1, None, False),
+        (2, {"enabled": False, "base_url": "http://wled.local", "presets": {"offline": 9}}, False),
+        (3, {"enabled": True, "base_url": "http://wled.local", "presets": {}}, False),
+    ]
+    manager = WLEDManager()
+    with (
+        patch("backend.app.api.routes.settings.get_setting", new=AsyncMock(return_value=False)),
+        patch("backend.app.main.wled_manager", manager),
+    ):
+        await initialize_wled_printers(db)
+    assert all(runtime.send_task is None for runtime in manager._runtime.values())
+    assert manager._client is None

BIN
frontend/public/img/printers/h3c.png


Некоторые файлы не были показаны из-за большого количества измененных файлов