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

Bambu Lab H3C support (#3442)

Adds the H3C (model code N10): its nine-slot AMS 3H, the nine-PrintTool
SwapTek rack and the per-PrintTool external inputs.

Tray numbering, one shared helper (backend/app/utils/tray_ids.py,
mirrored in frontend/src/utils/trayIds.ts). Bambu Studio's global tray
ids come from the unit's AMS type, bits 0-3 of `info`
(DevFilaSystem::GetTrayIndexMap):
  - AMS / AMS 2 Pro: 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 id
On a printer with a direct-connected nozzle rack, the external inputs
are 247-255, and input v feeds PrintTool 255 - v.

These now go through the helper:
  - the copies of ams_id * 4 + slot
  - the ad-hoc AMS-HT / external checks
  - the (unit << 8) | slot decoders (decode_wire_slot)
  - the firmware's own indexes (legacy_tray_index)
  - rack detection: one TrayLayout per printer, with a single accessor
    on each side
Numbering is unchanged for every other printer.

Slots are stored as (ams_id, tray_id): an AMS 3H slot is (32, 0-8),
per-tool inputs are (255, 2-8). Slot indices 4-8 are accepted only for
those two units, in requests and in the Spoolman slot-assignment CHECK
(migrated on SQLite and PostgreSQL). H3C queue items that stock dev
saved as 128-136 are rewritten to 28-36 once.

H3C:
  - The AMS 3H (unit 32) is one unit with nine trays. Its raw
    tray_now 0-8 becomes 28 + slot. Dispatch sends ams_mapping 28 + slot
    plus ams_mapping2 {32, k}, as Studio does.
  - Load and unload use Studio 02.09's direct-rack ams_change_filament.
    An unload ends with pulling the filament back by hand, since the
    AMS 3H can't rewind, and the UI says so. A load into a slot that
    reports no filament is refused.
  - Drying targets the zone_info zone that holds two slots, with zone_id
    and the filament's cooling_temp.
  - The chamber heater only heats in airduct heating mode, so Bambuddy
    switches the airduct before M141.
  - Progress includes the ~8 min start sequence, so a stopped print's
    usage is scaled by layers.
  - Firmware check source: the H3C release-history page.
  - Maintenance: the H3C's own 14 tasks with links to Bambu's guides.
    They are seeded and listed only when an H3C exists.

UI:
  - The AMS 3H card is laid out like the unit, with a dryer box around
    the drying zone's slots and the ambient readings in the header.
  - The 9-position PrintTool rack and compact per-tool external inputs.
  - "AMS 3H slot n" / "3H-n" labels, the H3C printer image, and the new
    strings in all locales.

Tested on an H3C (firmware 01.01.03.00) with an AMS 3H.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dalton Clift 3 дней назад
Родитель
Сommit
c8fb2c7ead
85 измененных файлов с 4863 добавлено и 619 удалено
  1. 2 1
      backend/app/api/routes/inventory.py
  2. 192 4
      backend/app/api/routes/maintenance.py
  3. 68 18
      backend/app/api/routes/printers.py
  4. 3 2
      backend/app/api/routes/slicer_presets.py
  5. 7 2
      backend/app/api/routes/spoolman.py
  6. 15 2
      backend/app/api/routes/spoolman_inventory.py
  7. 117 32
      backend/app/core/database.py
  8. 23 23
      backend/app/main.py
  9. 5 0
      backend/app/models/spool_assignment.py
  10. 10 9
      backend/app/models/spoolman_slot_assignment.py
  11. 7 1
      backend/app/schemas/printer.py
  12. 1 0
      backend/app/schemas/spool.py
  13. 22 7
      backend/app/schemas/spoolman.py
  14. 10 4
      backend/app/services/archive_cost_estimate.py
  15. 426 108
      backend/app/services/bambu_mqtt.py
  16. 6 5
      backend/app/services/camera.py
  17. 8 26
      backend/app/services/filament_deficit.py
  18. 15 0
      backend/app/services/firmware_check.py
  19. 5 4
      backend/app/services/kprofile_drift.py
  20. 3 2
      backend/app/services/notification_service.py
  21. 9 9
      backend/app/services/print_cost_estimate.py
  22. 29 18
      backend/app/services/print_scheduler.py
  23. 77 25
      backend/app/services/printer_manager.py
  24. 4 2
      backend/app/services/slot_nozzle.py
  25. 34 42
      backend/app/services/spool_assignment_notifications.py
  26. 2 1
      backend/app/services/spool_relocation.py
  27. 37 36
      backend/app/services/spoolman_tracking.py
  28. 35 58
      backend/app/services/usage_tracker.py
  29. 55 0
      backend/app/utils/printer_models.py
  30. 390 0
      backend/app/utils/tray_ids.py
  31. 58 0
      backend/tests/integration/test_printers_api.py
  32. 17 0
      backend/tests/integration/test_spoolman_slot_requests.py
  33. 771 0
      backend/tests/unit/test_h3c_ams3h.py
  34. 275 0
      backend/tests/unit/test_h3c_maintenance_firmware_usage.py
  35. 99 0
      backend/tests/unit/test_h3c_queue_tray_ids_migration.py
  36. 57 0
      backend/tests/unit/test_printer_models.py
  37. 195 0
      backend/tests/unit/test_spoolman_slot_rack_ams_migration.py
  38. 291 0
      backend/tests/unit/test_tray_ids.py
  39. BIN
      frontend/public/img/printers/h3c.png
  40. 46 0
      frontend/src/__tests__/components/AssignToAmsModal.test.tsx
  41. 22 0
      frontend/src/__tests__/components/QrAssignTargetModal.test.tsx
  42. 30 1
      frontend/src/__tests__/components/spoolbuddy/AmsUnitCard.test.tsx
  43. 43 0
      frontend/src/__tests__/hooks/useFilamentMapping.test.ts
  44. 25 0
      frontend/src/__tests__/pages/InventoryPageSpoolmanLocation.test.tsx
  45. 155 0
      frontend/src/__tests__/pages/PrintersPageAms3h.test.tsx
  46. 47 0
      frontend/src/__tests__/pages/QueuePage.test.tsx
  47. 37 0
      frontend/src/__tests__/pages/SpoolBuddyAmsPage.test.tsx
  48. 93 0
      frontend/src/__tests__/pages/SpoolBuddyInventoryAssignmentLabel.test.tsx
  49. 129 0
      frontend/src/__tests__/utils/ams3h.test.ts
  50. 16 0
      frontend/src/__tests__/utils/maintenanceWikiUrls.test.ts
  51. 12 0
      frontend/src/__tests__/utils/printer.test.ts
  52. 1 0
      frontend/src/__tests__/utils/printerModel.test.ts
  53. 241 0
      frontend/src/__tests__/utils/trayIds.test.ts
  54. 11 3
      frontend/src/api/client.ts
  55. 2 0
      frontend/src/components/AmsBackupModal.tsx
  56. 11 7
      frontend/src/components/QrAssignTargetModal.tsx
  57. 5 10
      frontend/src/components/spoolbuddy/AmsUnitCard.tsx
  58. 21 18
      frontend/src/components/spoolbuddy/AssignToAmsModal.tsx
  59. 10 6
      frontend/src/hooks/useFilamentMapping.ts
  60. 4 0
      frontend/src/i18n/locales/de.ts
  61. 4 0
      frontend/src/i18n/locales/en.ts
  62. 4 0
      frontend/src/i18n/locales/es.ts
  63. 4 0
      frontend/src/i18n/locales/fr.ts
  64. 4 0
      frontend/src/i18n/locales/it.ts
  65. 4 0
      frontend/src/i18n/locales/ja.ts
  66. 4 0
      frontend/src/i18n/locales/ko.ts
  67. 4 0
      frontend/src/i18n/locales/nl.ts
  68. 4 0
      frontend/src/i18n/locales/pl.ts
  69. 4 0
      frontend/src/i18n/locales/pt-BR.ts
  70. 4 0
      frontend/src/i18n/locales/ru.ts
  71. 4 0
      frontend/src/i18n/locales/sv.ts
  72. 4 0
      frontend/src/i18n/locales/tr.ts
  73. 4 0
      frontend/src/i18n/locales/uk.ts
  74. 4 0
      frontend/src/i18n/locales/zh-CN.ts
  75. 4 0
      frontend/src/i18n/locales/zh-TW.ts
  76. 6 7
      frontend/src/pages/InventoryPage.tsx
  77. 163 67
      frontend/src/pages/PrintersPage.tsx
  78. 11 8
      frontend/src/pages/QueuePage.tsx
  79. 30 30
      frontend/src/pages/spoolbuddy/SpoolBuddyAmsPage.tsx
  80. 5 7
      frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx
  81. 93 14
      frontend/src/utils/amsHelpers.ts
  82. 4 0
      frontend/src/utils/maintenanceWikiUrls.ts
  83. 2 0
      frontend/src/utils/printer.ts
  84. 3 0
      frontend/src/utils/printerModel.ts
  85. 145 0
      frontend/src/utils/trayIds.ts

+ 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.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.natural_sort import natural_sort_key
 from backend.app.utils.tag_normalization import normalize_tag_uid, normalize_tray_uuid
 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__)
 logger = logging.getLogger(__name__)
 
 
@@ -2460,7 +2461,7 @@ async def assign_spool(
         if data.ams_id == 255:
         if data.ams_id == 255:
             # External slot: look up tray from vt_tray by global ID
             # External slot: look up tray from vt_tray by global ID
             vt_tray = state.raw_data.get("vt_tray") or []
             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:
             for vt in vt_tray:
                 if isinstance(vt, dict) and int(vt.get("id", 254)) == ext_id:
                 if isinstance(vt, dict) and int(vt.get("id", 254)) == ext_id:
                     fingerprint_color = vt.get("tray_color", "")
                     fingerprint_color = vt.get("tray_color", "")

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

@@ -32,7 +32,7 @@ from backend.app.schemas.maintenance import (
     PrinterMaintenanceUpdate,
     PrinterMaintenanceUpdate,
 )
 )
 from backend.app.services.notification_service import notification_service
 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__)
 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.
 # System types that only apply to printers with a specific rod/rail type.
 # "carbon" = X1/P1 series (carbon rods), "steel_rod" = P2S (steel rods),
 # "carbon" = X1/P1 series (carbon rods), "steel_rod" = P2S (steel rods),
 # "linear_rail" = A1/H2 series. Types not listed here apply to all printers.
 # "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:
 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."""
     """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)
     rod_requirement = _ROD_TYPE_REQUIREMENTS.get(type_name)
     if rod_requirement is None:
     if rod_requirement is None:
         return True  # Not model-specific, applies to all
         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
     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:
 async def ensure_default_types(db: AsyncSession) -> None:
     """Ensure default maintenance types exist, remove stale/duplicate ones."""
     """Ensure default maintenance types exist, remove stale/duplicate ones."""
     result = await db.execute(
     result = await db.execute(
@@ -157,7 +325,12 @@ async def ensure_default_types(db: AsyncSession) -> None:
     )
     )
     existing = result.scalars().all()
     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)
     # Remove stale system types no longer in defaults (e.g. renamed types)
     # and deduplicate: if concurrent requests created the same type twice,
     # 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)
             seen_names.add(t.name)
 
 
     # Create any missing default types
     # 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:
         if type_def["name"] not in seen_names:
             new_type = MaintenanceType(
             new_type = MaintenanceType(
                 name=type_def["name"],
                 name=type_def["name"],
                 description=type_def["description"],
                 description=type_def["description"],
                 default_interval_hours=type_def["default_interval_hours"],
                 default_interval_hours=type_def["default_interval_hours"],
+                interval_type=type_def.get("interval_type", "hours"),
                 icon=type_def["icon"],
                 icon=type_def["icon"],
+                wiki_url=type_def.get("wiki_url"),
                 is_system=True,
                 is_system=True,
             )
             )
             db.add(new_type)
             db.add(new_type)
@@ -199,7 +374,8 @@ async def get_maintenance_types(
         .where(MaintenanceType.is_deleted.is_(False))
         .where(MaintenanceType.is_deleted.is_(False))
         .order_by(MaintenanceType.is_system.desc(), MaintenanceType.name)
         .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)
 @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
         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(
 async def _get_printer_maintenance_internal(
     printer_id: int,
     printer_id: int,
     db: AsyncSession,
     db: AsyncSession,
@@ -374,6 +560,7 @@ async def _get_printer_maintenance_internal(
         default_interval_type = getattr(maint_type, "interval_type", "hours") or "hours"
         default_interval_type = getattr(maint_type, "interval_type", "hours") or "hours"
 
 
         if item:
         if item:
+            _start_model_task_clock(item, maint_type, printer, now)
             interval = item.custom_interval_hours or maint_type.default_interval_hours
             interval = item.custom_interval_hours or maint_type.default_interval_hours
             # Use custom interval type if set, otherwise use type's default
             # Use custom interval type if set, otherwise use type's default
             interval_type = getattr(item, "custom_interval_type", None) or default_interval_type
             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
             # Create default entry for this printer/type, or take the one a
             # concurrent read just created.
             # concurrent read just created.
             item = await _get_or_create_maintenance_item(db, printer_id, maint_type.id)
             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 = item.custom_interval_hours or maint_type.default_interval_hours
             interval_type = getattr(item, "custom_interval_type", None) or default_interval_type
             interval_type = getattr(item, "custom_interval_type", None) or default_interval_type

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

@@ -120,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.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.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.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__)
 logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/printers", tags=["printers"])
 router = APIRouter(prefix="/printers", tags=["printers"])
@@ -767,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)
     # Virtual tray (external spool holder) - comes from vt_tray in raw_data (list)
     if "vt_tray" in raw_data:
     if "vt_tray" in raw_data:
         vt_tray = [AMSTray(**vt) for vt in vt_trays_to_dicts(raw_data["vt_tray"], _kprofile_k)]
         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
     # Convert nozzle info to response format
     nozzles = [
     nozzles = [
@@ -790,6 +800,7 @@ async def _printer_status(db: AsyncSession, printer: Printer) -> PrinterStatus:
             filament_color=n.get("filament_color", ""),
             filament_color=n.get("filament_color", ""),
             filament_id=n.get("filament_id", ""),
             filament_id=n.get("filament_id", ""),
             filament_type=n.get("filament_type", ""),
             filament_type=n.get("filament_type", ""),
+            addr_id=n.get("addr_id"),
         )
         )
         for n in (state.nozzle_rack or [])
         for n in (state.nozzle_rack or [])
     ]
     ]
@@ -912,6 +923,7 @@ async def _printer_status(db: AsyncSession, printer: Printer) -> PrinterStatus:
                 raw_data.get("mapping"),
                 raw_data.get("mapping"),
                 active_extruder=state.active_extruder,
                 active_extruder=state.active_extruder,
                 ams_extruder_map=nozzle_wiring(state),
                 ams_extruder_map=nozzle_wiring(state),
+                tray_layout=tray_layout,
             )
             )
             if state.state == "PAUSE"
             if state.state == "PAUSE"
             else None
             else None
@@ -923,6 +935,7 @@ async def _printer_status(db: AsyncSession, printer: Printer) -> PrinterStatus:
                 raw_data.get("mapping"),
                 raw_data.get("mapping"),
                 active_extruder=state.active_extruder,
                 active_extruder=state.active_extruder,
                 ams_extruder_map=nozzle_wiring(state),
                 ams_extruder_map=nozzle_wiring(state),
+                tray_layout=tray_layout,
             )
             )
             if state.state == "PAUSE"
             if state.state == "PAUSE"
             else None
             else None
@@ -2654,12 +2667,11 @@ async def start_calibration(
 
 
 
 
 def _slot_preset_key(ams_id: int, tray_id: int) -> int:
 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")
 @router.get("/{printer_id}/slot-presets")
@@ -3116,7 +3128,7 @@ async def configure_ams_slot(
 
 
             if ams_id == 255:
             if ams_id == 255:
                 vt_tray = state.raw_data.get("vt_tray") or []
                 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:
                 for vt in vt_tray:
                     if isinstance(vt, dict) and int(vt.get("id", 254)) == ext_id:
                     if isinstance(vt, dict) and int(vt.get("id", 254)) == ext_id:
                         current_tray_info_idx = vt.get("tray_info_idx", "")
                         current_tray_info_idx = vt.get("tray_info_idx", "")
@@ -3265,16 +3277,16 @@ async def configure_ams_slot(
     # hardcoded extruder_id=0 and nozzle_id=HS00.
     # hardcoded extruder_id=0 and nozzle_id=HS00.
     k_value_unsent = False
     k_value_unsent = False
     if k_value > 0 and cali_idx < 0:
     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:
         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:
         else:
-            global_tray_id = tray_id
+            cali_tray_id = tray_id
 
 
         k_value_unsent = not client.extrusion_cali_set(
         k_value_unsent = not client.extrusion_cali_set(
-            tray_id=global_tray_id,
+            tray_id=cali_tray_id,
             k_value=k_value,
             k_value=k_value,
             nozzle_diameter=nozzle_diameter,
             nozzle_diameter=nozzle_diameter,
             nozzle_temp=nozzle_temp_max,
             nozzle_temp=nozzle_temp_max,
@@ -4714,15 +4726,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
 # 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
 # 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 = (
 _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:
 def _is_valid_load_tray_id(tray_id: int) -> bool:
     """Whether ``tray_id`` names a slot the load/unload commands can address."""
     """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")
 @router.post("/{printer_id}/ams/load")
@@ -4752,6 +4785,7 @@ async def ams_load(
 
 
     Tray ID encoding (matches Bambu firmware convention):
     Tray ID encoding (matches Bambu firmware convention):
     - 0..15: AMS slot, computed as ams_id * 4 + slot_id
     - 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)
     - 128..135: AMS-HT, the unit id (one tray per unit)
     - 254: external spool (single-external printers, or Ext-L on dual-nozzle H2D)
     - 254: external spool (single-external printers, or Ext-L on dual-nozzle H2D)
     - 255: Ext-R on dual-nozzle H2D
     - 255: Ext-R on dual-nozzle H2D
@@ -4768,18 +4802,33 @@ async def ams_load(
     if not client:
     if not client:
         raise HTTPException(400, "Printer not connected")
         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)
     success = client.ams_load_filament(tray_id, extruder_id=extruder_id)
     if not success:
     if not success:
         raise HTTPException(500, "Failed to send load command")
         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"
         target = "external spool"
     elif tray_id == 255:
     elif tray_id == 255:
         target = "Ext-R"
         target = "Ext-R"
     elif tray_id >= 128:
     elif tray_id >= 128:
         target = f"AMS HT-{chr(ord('A') + tray_id - 128)}"
         target = f"AMS HT-{chr(ord('A') + tray_id - 128)}"
     else:
     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}"}
     return {"success": True, "message": f"Loading filament from {target}"}
 
 
 
 
@@ -4811,6 +4860,7 @@ async def ams_unload(
     client = printer_manager.get_client(printer_id)
     client = printer_manager.get_client(printer_id)
     if not client:
     if not client:
         raise HTTPException(400, "Printer not connected")
         raise HTTPException(400, "Printer not connected")
+    _refuse_foreign_ams3h_tray(tray_id, _client_tray_layout(client))
 
 
     success = client.ams_unload_filament(tray_id)
     success = client.ams_unload_filament(tray_id)
     if not success:
     if not success:

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

@@ -76,6 +76,7 @@ from backend.app.utils.printer_models import (
     normalize_printer_model_id,
     normalize_printer_model_id,
 )
 )
 from backend.app.utils.slicer_strings import first_slicer_string, slicer_strings
 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__)
 logger = logging.getLogger(__name__)
 
 
@@ -902,7 +903,7 @@ async def list_loaded_spools(
                 for t in raw_trays
                 for t in raw_trays
             ]
             ]
             # Same rule as the printer status: an AMS-HT is a unit id 128-135.
             # 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] = []
         external: list[LoadedSpoolTray] = []
         holders = [vt for vt in raw_data.get("vt_tray") or [] if isinstance(vt, dict)]
         holders = [vt for vt in raw_data.get("vt_tray") or [] if isinstance(vt, dict)]
@@ -911,7 +912,7 @@ async def list_loaded_spools(
                 continue
                 continue
             # The holders are trays 254 / 255; slot presets key them as AMS 255,
             # The holders are trays 254 / 255; slot presets key them as AMS 255,
             # tray 0 / 1, the way the printer card saves them.
             # 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))))
             external.append(_loaded_spool_tray(vt, 255, tray_id, saved.get((printer.id, 255, tray_id))))
 
 
         out.append(
         out.append(

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

@@ -5,7 +5,7 @@ import logging
 from typing import Literal
 from typing import Literal
 
 
 from fastapi import APIRouter, Depends, HTTPException
 from fastapi import APIRouter, Depends, HTTPException
-from pydantic import BaseModel
+from pydantic import BaseModel, model_validator
 from sqlalchemy import delete, select, text
 from sqlalchemy import delete, select, text
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 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_k_profile import SpoolmanKProfile
 from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 from backend.app.models.user import User
 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 import slot_unlink_grace, spool_swaps
 from backend.app.services.ams_slot_presence import spool_present
 from backend.app.services.ams_slot_presence import spool_present
 from backend.app.services.inventory_mode import spoolman_owns_assignments
 from backend.app.services.inventory_mode import spoolman_owns_assignments
@@ -858,6 +858,11 @@ class LinkSpoolRequest(BaseModel):
     ams_id: SlotAmsId | None = None
     ams_id: SlotAmsId | None = None
     tray_id: SlotTrayId | 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")
 @router.post("/spools/{spool_id}/link")
 async def link_spool(
 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.models.user import User
 from backend.app.schemas.spool import SpoolFilamentPresetBase, SpoolKProfileBase, naive_utc
 from backend.app.schemas.spool import SpoolFilamentPresetBase, SpoolKProfileBase, naive_utc
 from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
 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.schemas.supplier import SpoolSupplierLinkInput
 from backend.app.services import filament_profile_default as profile_defaults, slot_unlink_grace, spool_swaps
 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
 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,
     normalize_slicer_filament,
 )
 )
 from backend.app.utils.filament_types import nozzle_temp_range, printer_filament_type
 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__)
 logger = logging.getLogger(__name__)
 
 
@@ -506,6 +513,11 @@ class SpoolSlotAssignmentRequest(BaseModel):
     # transaction (#1574): a scanned spool is the one in the user's hand.
     # transaction (#1574): a scanned spool is the one in the user's hand.
     move: bool = False
     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
 # Endpoints
@@ -1720,6 +1732,7 @@ async def get_all_spoolman_slot_assignments(
                 tray_id=s.tray_id,
                 tray_id=s.tray_id,
                 spoolman_spool_id=s.spoolman_spool_id,
                 spoolman_spool_id=s.spoolman_spool_id,
                 ams_label=_ams_label_for(s.printer_id, s.ams_id),
                 ams_label=_ams_label_for(s.printer_id, s.ams_id),
+                printer_model=s.printer.model if s.printer else None,
             )
             )
         )
         )
     return enriched
     return enriched
@@ -1883,7 +1896,7 @@ def _live_slot_tray(state, ams_id: int, tray_id: int) -> dict:
     if ams_id == 255:
     if ams_id == 255:
         vt_trays = raw.get("vt_tray") or []
         vt_trays = raw.get("vt_tray") or []
         for vt in [vt_trays] if isinstance(vt_trays, dict) else vt_trays:
         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 vt
         return {}
         return {}
     units = raw.get("ams") or []
     units = raw.get("ams") or []

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

@@ -1677,32 +1677,41 @@ async def _migrate_update_auto_link_constraint(conn) -> None:
 
 
 
 
 async def _migrate_widen_spoolman_slot_ams_id_range(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.
     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
     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():
     if not is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE spoolman_slot_assignments DROP CONSTRAINT IF EXISTS ck_ams_id_range")
         await _safe_execute(
         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(
         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
         return
 
 
@@ -1714,8 +1723,8 @@ async def _migrate_widen_spoolman_slot_ams_id_range(conn) -> None:
     if not row:
     if not row:
         return
         return
     sql = row[0] or ""
     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
         return
     # Pre-migration table without any CHECK constraint at all → leave alone;
     # Pre-migration table without any CHECK constraint at all → leave alone;
     # the app-level validation handles correctness and we don't risk a
     # the app-level validation handles correctness and we don't risk a
@@ -1731,29 +1740,38 @@ async def _migrate_widen_spoolman_slot_ams_id_range(conn) -> None:
                     "CREATE TABLE spoolman_slot_assignments_v2 ("
                     "CREATE TABLE spoolman_slot_assignments_v2 ("
                     "id INTEGER PRIMARY KEY AUTOINCREMENT, "
                     "id INTEGER PRIMARY KEY AUTOINCREMENT, "
                     "printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE, "
                     "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, "
                     "spoolman_spool_id INTEGER NOT NULL, "
                     "assigned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "
                     "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(
             await conn.execute(
                 text(
                 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) "
                     "(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 "
                     "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()
             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()
             copied = (await conn.execute(text("SELECT count(*) FROM spoolman_slot_assignments_v2"))).scalar_one()
-            if copied != original:
+            if copied != kept:
                 raise RuntimeError(
                 raise RuntimeError(
                     f"spoolman_slot_assignments migration: row count mismatch after copy "
                     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("DROP TABLE spoolman_slot_assignments"))
             await conn.execute(text("ALTER TABLE spoolman_slot_assignments_v2 RENAME TO 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
             # The index sits on the renamed table; recreate it idempotently
@@ -4692,18 +4710,21 @@ async def _run_migrations(conn):
     # Migration: Create spoolman_slot_assignments table for local AMS-slot→Spoolman-spool mapping.
     # 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
     # Replaces the pattern of writing spool.location in Spoolman (which polluted the
     # user-editable storage_location field in the UI).
     # 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(
     await _safe_execute(
         conn,
         conn,
         """
         """
         CREATE TABLE IF NOT EXISTS spoolman_slot_assignments (
         CREATE TABLE IF NOT EXISTS spoolman_slot_assignments (
             id INTEGER PRIMARY KEY AUTOINCREMENT,
             id INTEGER PRIMARY KEY AUTOINCREMENT,
             printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE,
             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,
             spoolman_spool_id INTEGER NOT NULL,
             assigned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
             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()
         if is_sqlite()
@@ -4711,11 +4732,13 @@ async def _run_migrations(conn):
         CREATE TABLE IF NOT EXISTS spoolman_slot_assignments (
         CREATE TABLE IF NOT EXISTS spoolman_slot_assignments (
             id SERIAL PRIMARY KEY,
             id SERIAL PRIMARY KEY,
             printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE,
             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,
             spoolman_spool_id INTEGER NOT NULL,
             assigned_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
             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 +4748,8 @@ async def _run_migrations(conn):
     )
     )
 
 
     # Migration: widen ck_ams_id_range on spoolman_slot_assignments to allow
     # 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)
     await _migrate_widen_spoolman_slot_ams_id_range(conn)
 
 
     # Migration: Create spoolman_k_profile table for K-value calibration profiles linked to Spoolman spools.
     # Migration: Create spoolman_k_profile table for K-value calibration profiles linked to Spoolman spools.
@@ -6040,6 +6063,7 @@ async def _run_migrations(conn):
     await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN plate_estimates JSON")
     await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN plate_estimates JSON")
 
 
     await _backfill_archive_run_photos(conn)
     await _backfill_archive_run_photos(conn)
+    await _migrate_h3c_queue_tray_ids(conn)
 
 
     await _migrate_auth_datetimes_to_timestamp(conn)
     await _migrate_auth_datetimes_to_timestamp(conn)
     await _migrate_add_missing_set_null_fks(conn)
     await _migrate_add_missing_set_null_fks(conn)
@@ -6092,6 +6116,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"})
         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:
 async def _backfill_archive_run_photos(conn) -> None:
     """Put back on its archive a finish photo only its run kept.
     """Put back on its archive a finish photo only its run kept.
 
 

+ 23 - 23
backend/app/main.py

@@ -159,6 +159,7 @@ from backend.app.services.printer_manager import (
     parse_plate_id,
     parse_plate_id,
     printer_manager,
     printer_manager,
     printer_state_to_dict,
     printer_state_to_dict,
+    printer_tray_layout,
     resolve_plate_id,
     resolve_plate_id,
 )
 )
 from backend.app.services.slot_kprofile import find_slot_kprofile_for_extruder
 from backend.app.services.slot_kprofile import find_slot_kprofile_for_extruder
@@ -186,6 +187,7 @@ from backend.app.utils.filament_types import printer_filament_type
 from backend.app.utils.fts_routing import extruder_for_inlet
 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.local_time import to_naive_utc, utcnow_naive
 from backend.app.utils.print_jobs import is_internal_printer_job
 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
 
 
 
 
 # =============================================================================
 # =============================================================================
@@ -1808,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
     from backend.app.services.print_scheduler import _global_tray_id
 
 
     # Map global tray id → (type, color), addressed as the print's ams_mapping
     # 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]] = {}
     loaded: dict[int, tuple[str, str]] = {}
     for unit in ams_units:
     for unit in ams_units:
         if not isinstance(unit, dict):
         if not isinstance(unit, dict):
@@ -1831,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 ""
             tcolor = f"#{hex_color}" if re.fullmatch(r"[0-9A-F]{6}", hex_color) else ""
             if not ttype:
             if not ttype:
                 continue  # Empty / unloaded slot.
                 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:
     if not loaded:
         return result
         return result
@@ -2736,12 +2739,7 @@ def _slot_feeds_print(printer_id: int, ams_id: int, tray_id: int) -> bool:
     status = printer_manager.get_status(printer_id)
     status = printer_manager.get_status(printer_id)
     if status is None:
     if status is None:
         return False
         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:
     if getattr(status, "tray_now", None) == global_id:
         return True
         return True
     slots = getattr(status, "extruder_slots", None) or {}
     slots = getattr(status, "extruder_slots", None) or {}
@@ -2796,7 +2794,7 @@ async def _unlink_stale_assignments(printer_id: int, ams_data: list, printing_no
                 if assignment.ams_id == 255:
                 if assignment.ams_id == 255:
                     ps = printer_manager.get_status(printer_id)
                     ps = printer_manager.get_status(printer_id)
                     vt_tray_raw = ps.raw_data.get("vt_tray", []) if ps else []
                     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
                     current_tray = None
                     for vt in vt_tray_raw:
                     for vt in vt_tray_raw:
                         if isinstance(vt, dict) and int(vt.get("id", 254)) == ext_id:
                         if isinstance(vt, dict) and int(vt.get("id", 254)) == ext_id:
@@ -8234,8 +8232,16 @@ async def reconcile_stale_active_prints(printer_id: int) -> int:
         if outcome != "aborted":
         if outcome != "aborted":
             # The printer's progress and layer are this job's, so a partial
             # The printer's progress and layer are this job's, so a partial
             # run is charged what it printed rather than nothing.
             # 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:
             if state.layer_num:
                 payload["last_layer_num"] = state.layer_num
                 payload["last_layer_num"] = state.layer_num
         if outcome == "failed":
         if outcome == "failed":
@@ -11117,11 +11123,9 @@ async def record_ams_history():
                         recorded_count += 1
                         recorded_count += 1
 
 
                         # Generate AMS label and determine if it's AMS-HT (A, B, C, D or HT-A for AMS-Lite/Hub)
                         # 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 /
                         # Skip alarm dispatch for empty AMS units — humidity /
                         # temperature readings are ambient with no filament to
                         # temperature readings are ambient with no filament to
@@ -12231,13 +12235,9 @@ async def lifespan(app: FastAPI):
                 _slot_label_from_global_tray,
                 _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_info = printer_manager.get_printer(printer_id)
             printer_name = printer_info.name if printer_info else f"Printer {printer_id}"
             printer_name = printer_info.name if printer_info else f"Printer {printer_id}"

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

@@ -30,6 +30,11 @@ class SpoolAssignment(Base):
         """Get printer name from loaded relationship."""
         """Get printer name from loaded relationship."""
         return self.printer.name if self.printer else None
         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.printer import Printer  # noqa: E402, F401
 from backend.app.models.spool import Spool  # 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
 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):
 class SpoolmanSlotAssignment(Base):
     """Assignment of a Spoolman spool to a specific AMS slot on a printer.
     """Assignment of a Spoolman spool to a specific AMS slot on a printer.
@@ -27,15 +31,12 @@ class SpoolmanSlotAssignment(Base):
 
 
     __table_args__ = (
     __table_args__ = (
         UniqueConstraint("printer_id", "ams_id", "tray_id", name="uq_slot_assignment"),
         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"),
     )
     )
 
 
 
 

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

@@ -330,7 +330,12 @@ class AMSUnit(BaseModel):
     dry_sf_reason: list[int] = []  # Cannot-dry reasons from firmware (see CannotDryReason)
     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_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
     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):
 class NozzleInfoResponse(BaseModel):
@@ -351,6 +356,7 @@ class NozzleRackSlot(BaseModel):
     filament_color: str = ""  # RGBA hex ("00000000" = no filament)
     filament_color: str = ""  # RGBA hex ("00000000" = no filament)
     filament_id: str = ""  # Bambu filament ID
     filament_id: str = ""  # Bambu filament ID
     filament_type: str = ""  # Material type (e.g. "PLA", "PETG")
     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):
 class AmsLabelBody(BaseModel):

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

@@ -356,6 +356,7 @@ class SpoolAssignmentResponse(BaseModel):
     configured: bool = False
     configured: bool = False
     pending_config: bool = False  # True when slot was empty at assign time; will configure on insert
     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
     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:
     class Config:
         from_attributes = True
         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 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:
 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
         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)]
 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):
 class SpoolmanFilamentPatch(BaseModel):
@@ -44,3 +57,5 @@ class SpoolmanSlotAssignmentEnriched(BaseModel):
     tray_id: int
     tray_id: int
     spoolman_spool_id: int
     spoolman_spool_id: int
     ams_label: str | None
     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 sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.core.config import settings as app_settings
 from backend.app.core.config import settings as app_settings
+from backend.app.utils.tray_ids import RACK_EXTERNAL_IDS
 
 
 logger = logging.getLogger(__name__)
 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:
 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 import Spool
     from backend.app.models.spool_assignment import SpoolAssignment
     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
     from backend.app.services.spoolman_tracking import _global_tray_id_to_ams_slot
 
 
     rates: dict[int, float] = {}
     rates: dict[int, float] = {}
+    layout = printer_tray_layout(printer_id)
     for tray in trays:
     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 = (
         spool = (
             await db.execute(
             await db.execute(
                 select(Spool)
                 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
     slot assignment, #1459) and prices it with ``_spool_cost_per_gram``, so the
     estimate and the completion figure read the same price fields.
     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 import get_spoolman_client
     from backend.app.services.spoolman_tracking import (
     from backend.app.services.spoolman_tracking import (
         _get_printer_serial,
         _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:
     if client is None:
         return {}
         return {}
     serial = await _get_printer_serial(printer_id)
     serial = await _get_printer_serial(printer_id)
+    layout = printer_tray_layout(printer_id)
 
 
     rates: dict[int, float] = {}
     rates: dict[int, float] = {}
     for tray in trays:
     for tray in trays:
@@ -138,11 +144,11 @@ async def _spoolman_rates(printer_id: int, trays: set[int], ams_trays: dict[int,
         try:
         try:
             tray_info = ams_trays.get(tray)
             tray_info = ams_trays.get(tray)
             if tray_info:
             if tray_info:
-                tag = _resolve_spool_tag(tray_info, serial, tray)
+                tag = _resolve_spool_tag(tray_info, serial, tray, layout)
                 if tag:
                 if tag:
                     spool = await client.find_spool_by_tag(tag)
                     spool = await client.find_spool_by_tag(tag)
             if spool is None:
             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)
                 spool_id = await _resolve_spool_id_via_slot_assignment(printer_id, ams_id, tray_id)
                 if spool_id is not None:
                 if spool_id is not None:
                     spool = await client.get_spool(spool_id)
                     spool = await client.get_spool(spool_id)

+ 426 - 108
backend/app/services/bambu_mqtt.py

@@ -26,6 +26,24 @@ from backend.app.services.hms_errors import alert_level_from_print_error, descri
 from backend.app.utils.ams_drying import ACTIVE_DRY_STATUSES, DRY_COUNTDOWN_STALL_SECONDS
 from backend.app.utils.ams_drying import ACTIVE_DRY_STATUSES, DRY_COUNTDOWN_STALL_SECONDS
 from backend.app.utils.ams_humidity import ams_humidity_percent
 from backend.app.utils.ams_humidity import ams_humidity_percent
 from backend.app.utils.paho_teardown import discard_queued_publishes, retire_paho_client
 from backend.app.utils.paho_teardown import discard_queued_publishes, retire_paho_client
+from backend.app.utils.tray_ids import (
+    AMS3H_UNIT_ID,
+    RACK_SLOT_COUNT,
+    AmsType,
+    TrayLayout,
+    ams_type_from_info,
+    decode_wire_slot,
+    external_tray_id,
+    global_tray_id,
+    is_ams_ht_id,
+    is_external_tray,
+    is_rack_ams,
+    legacy_tray_index,
+    mapped_trays_with_slot,
+    tray_flag_bit,
+    unit_ams_type,
+    unit_label,
+)
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -40,7 +58,8 @@ PENDING_TRAY_TARGET_TTL = 120.0
 #   "ams_f1/<id>"  – AMS Lite (A1, A1 Mini; the A2L reports id 16, stored as 6)
 #   "ams_f1/<id>"  – AMS Lite (A1, A1 Mini; the A2L reports id 16, stored as 6)
 #   "n3f/<id>"     – AMS 2 Pro (H2D Pro and similar)
 #   "n3f/<id>"     – AMS 2 Pro (H2D Pro and similar)
 #   "n3s/<id>"     – AMS HT (H2D Pro and similar; IDs typically start at 128)
 #   "n3s/<id>"     – AMS HT (H2D Pro and similar; IDs typically start at 128)
-_AMS_MODULE_PREFIXES = ("ams/", "ams_f1/", "n3f/", "n3s/")
+#   "n10a"         – AMS 3H (H3C), no suffix: read as "n10a/<its unit id>"
+_AMS_MODULE_PREFIXES = ("ams/", "ams_f1/", "n3f/", "n3s/", "n10a/")
 
 
 # gcode_state values that mean the printer is not idle and must not be handed a
 # gcode_state values that mean the printer is not idle and must not be handed a
 # new start-print (#2598). The firmware rejects a project_file while busy with
 # new start-print (#2598). The firmware rejects a project_file while busy with
@@ -198,11 +217,11 @@ def a2l_lite_wire_ids(ams_id: int, tray_id: int) -> tuple[int, int, int] | None:
     return (
     return (
         A2L_LITE_PHYSICAL_AMS_ID,
         A2L_LITE_PHYSICAL_AMS_ID,
         local_slot,
         local_slot,
-        A2L_LITE_PHYSICAL_AMS_ID * 4 + local_slot,
+        legacy_tray_index(A2L_LITE_PHYSICAL_AMS_ID, local_slot),
     )
     )
 
 
 
 
-def change_filament_wire_ids(tray_id: int) -> tuple[int, int, int]:
+def change_filament_wire_ids(tray_id: int, layout: TrayLayout | None = None) -> tuple[int, int, int]:
     """``(ams_id, slot_id, target)`` of ``ams_change_filament`` for an AMS tray.
     """``(ams_id, slot_id, target)`` of ``ams_change_filament`` for an AMS tray.
 
 
     As BambuStudio's ``command_ams_change_filament`` builds them: a regular AMS
     As BambuStudio's ``command_ams_change_filament`` builds them: a regular AMS
@@ -211,14 +230,106 @@ def change_filament_wire_ids(tray_id: int) -> tuple[int, int, int]:
     the unit id itself as target. An AMS-HT holds one tray, so its global tray
     the unit id itself as target. An AMS-HT holds one tray, so its global tray
     id is its unit id and its slot is 0.
     id is its unit id and its slot is 0.
     """
     """
-    if 128 <= tray_id <= 135:
+    if is_ams_ht_id(tray_id):
         return tray_id, 0, tray_id
         return tray_id, 0, tray_id
-    if (a2l := a2l_lite_wire_ids(tray_id // 4, tray_id)) is not None:
+    ams_id, slot_id = (layout or TrayLayout()).slot_key(tray_id)
+    if (a2l := a2l_lite_wire_ids(ams_id, tray_id)) is not None:
         return a2l[0], a2l[1], a2l[0]
         return a2l[0], a2l[1], a2l[0]
-    return tray_id // 4, tray_id % 4, tray_id
+    return ams_id, slot_id, tray_id
+
+
+# The H3C's AMS 3H is one unit (32, AMS type 6) with nine slots, numbered 28 + k
+# (utils.tray_ids). Its tray_now / tray_tar / tray_pre are the raw slot 0-8;
+# print.mapping and the snow fields encode a slot as (32 << 8) | k. Commands
+# address it as {ams_id: 32, slot_id: k}.
+AMS3H_MODULE_NAME = "n10a"  # its get_version module (no "/<unit>" suffix)
+
+
+def _mqtt_int(value: object) -> int | None:
+    """An MQTT id / number field as an int (they arrive as int or numeric str), else None."""
+    if isinstance(value, bool) or not isinstance(value, (int, str)):
+        return None
+    try:
+        return int(value)
+    except ValueError:
+        return None
+
+
+# AMS 3H drying (Bambu Studio 02.09, AMSDryControl.cpp): the unit reports
+# `zone_info[]` and only a zone with exactly two slots can dry; the others are
+# ambient readings. A drying start carries the filament's softening temperature
+# as `cooling_temp` (the preset's filament_dev_drying_softening_temperature).
+AMS3H_DRY_COOLING_TEMP = {"PLA": 65, "PETG": 75, "ABS": 95, "ASA": 95, "TPU": 70, "PA": 150, "PC": 120}
+AMS3H_DRY_COOLING_TEMP_DEFAULT = 50
+
+
+def ams3h_drying_zone(zone_info: object) -> dict | None:
+    """The zone of an AMS 3H's `zone_info` that can dry: the first holding exactly two slots."""
+    if not isinstance(zone_info, list):
+        return None
+    for zone in zone_info:
+        if isinstance(zone, dict) and isinstance(zone.get("zone_slots"), list) and len(zone["zone_slots"]) == 2:
+            return zone
+    return None
+
+
+def ams3h_drying_cooling_temp(filament: str) -> int:
+    """`cooling_temp` for an AMS 3H drying start: the filament's softening temperature."""
+    base = (filament or "").upper().split("-")[0].split(" ")[0]
+    return AMS3H_DRY_COOLING_TEMP.get(base, AMS3H_DRY_COOLING_TEMP_DEFAULT)
+
+
+def _ams3h_unit_drying_fields(unit: dict) -> dict:
+    """An AMS 3H's drying state, which lives on its drying zone, lifted onto the unit."""
+    zone = ams3h_drying_zone(unit.get("zone_info"))
+    if zone is None:
+        return {}
+    fields: dict = {"dry_zone_id": zone.get("zone_id", 0), "dry_zone_slots": list(zone.get("zone_slots") or [])}
+    for key in ("dry_time", "dry_setting", "dry_sf_reason"):
+        if key in zone:
+            fields[key] = zone[key]
+    if "info" not in unit and "info" in zone:
+        fields["info"] = zone["info"]
+    if "humidity_raw" in zone:
+        fields["dry_zone_humidity_raw"] = zone["humidity_raw"]
+    if "temp" in zone:
+        fields["dry_zone_temp"] = zone["temp"]
+    return fields
+
+
+def _ams3h_unit_env_fields(unit: dict) -> dict:
+    """An AMS 3H's humidity and temperature (the unit has none) from its first non-drying zone, as Studio shows it."""
+    zones = unit.get("zone_info")
+    if not isinstance(zones, list):
+        return {}
+    dry_zone = ams3h_drying_zone(zones)
+    ambient = next((z for z in zones if isinstance(z, dict) and z is not dry_zone), None)
+    if ambient is None:
+        return {}
+    return {key: ambient[key] for key in ("humidity_raw", "temp") if key in ambient and key not in unit}
+
+
+def apply_rack_ams_zone_fields(ams_list: object, known_types: dict[int, AmsType] | None = None) -> None:
+    """Lift a rack AMS's per-zone readings and drying state onto the unit, in place (type from `info`, else ``known_types``)."""
+    if not isinstance(ams_list, list):
+        return
+    for unit in ams_list:
+        if not isinstance(unit, dict) or not isinstance(unit.get("zone_info"), list):
+            continue
+        uid = _mqtt_int(unit.get("id"))
+        if uid is None:
+            continue
+        kind = ams_type_from_info(unit.get("info"))
+        if kind is None:
+            kind = (known_types or {}).get(uid, unit_ams_type(uid))
+        if not is_rack_ams(kind):
+            continue
+        unit.update({**_ams3h_unit_env_fields(unit), **_ams3h_unit_drying_fields(unit)})
 
 
 
 
-def resolve_external_spools_in_mapping(ams_mapping: object, ams_mapping2: object, is_dual_nozzle: bool) -> object:
+def resolve_external_spools_in_mapping(
+    ams_mapping: object, ams_mapping2: object, is_dual_nozzle: bool, layout: TrayLayout | None = None
+) -> object:
     """Put the external spool back into a captured flat ``ams_mapping`` (#3166).
     """Put the external spool back into a captured flat ``ams_mapping`` (#3166).
 
 
     The firmware rejects 254/255 in the flat list, so BambuStudio and our own
     The firmware rejects 254/255 in the flat list, so BambuStudio and our own
@@ -237,7 +348,9 @@ def resolve_external_spools_in_mapping(ams_mapping: object, ams_mapping2: object
     An A2L AMS-Lite tray is the local slot 0-3 in the flat list and
     An A2L AMS-Lite tray is the local slot 0-3 in the flat list and
     ``{ams_id: 16, slot_id: n}`` in ``ams_mapping2``; it becomes its normalised
     ``{ams_id: 16, slot_id: n}`` in ``ams_mapping2``; it becomes its normalised
     global tray 24+n. Kept local, it read as a regular AMS tray, so usage was
     global tray 24+n. Kept local, it read as a regular AMS tray, so usage was
-    charged to the wrong spool. Every other entry is left exactly as captured.
+    charged to the wrong spool. A rack AMS slot (``{ams_id: 32, slot_id: k}``)
+    becomes its global tray 28+k, and on a rack printer each external input
+    247-255 keeps its own id. Every other entry is left exactly as captured.
     Without a usable ``ams_mapping2`` the input is returned unchanged.
     Without a usable ``ams_mapping2`` the input is returned unchanged.
     """
     """
     if not isinstance(ams_mapping, list) or not isinstance(ams_mapping2, list):
     if not isinstance(ams_mapping, list) or not isinstance(ams_mapping2, list):
@@ -245,6 +358,7 @@ def resolve_external_spools_in_mapping(ams_mapping: object, ams_mapping2: object
     if len(ams_mapping2) != len(ams_mapping):
     if len(ams_mapping2) != len(ams_mapping):
         return ams_mapping
         return ams_mapping
     resolved = list(ams_mapping)
     resolved = list(ams_mapping)
+    layout = layout or TrayLayout()
     for i, (flat, detail) in enumerate(zip(ams_mapping, ams_mapping2, strict=True)):
     for i, (flat, detail) in enumerate(zip(ams_mapping, ams_mapping2, strict=True)):
         if not isinstance(detail, dict):
         if not isinstance(detail, dict):
             continue
             continue
@@ -253,9 +367,18 @@ def resolve_external_spools_in_mapping(ams_mapping: object, ams_mapping2: object
         if ams_id == A2L_LITE_PHYSICAL_AMS_ID and slot_id in (0, 1, 2, 3):
         if ams_id == A2L_LITE_PHYSICAL_AMS_ID and slot_id in (0, 1, 2, 3):
             resolved[i] = A2L_LITE_GLOBAL_BASE + slot_id
             resolved[i] = A2L_LITE_GLOBAL_BASE + slot_id
             continue
             continue
-        if flat != -1 or ams_id not in (254, 255) or slot_id == 255:
+        if (
+            isinstance(ams_id, int)
+            and isinstance(slot_id, int)
+            and not is_external_tray(ams_id, layout.rack)
+            and is_rack_ams(layout.ams_type(ams_id))
+            and 0 <= slot_id < RACK_SLOT_COUNT
+        ):
+            resolved[i] = layout.global_tray_id(ams_id, slot_id)
+            continue
+        if flat != -1 or not is_external_tray(ams_id, layout.rack) or slot_id == 255:
             continue
             continue
-        resolved[i] = ams_id if is_dual_nozzle else 254
+        resolved[i] = ams_id if is_dual_nozzle or layout.rack else 254
     return resolved
     return resolved
 
 
 
 
@@ -376,9 +499,10 @@ def apply_tray_exist_bits(
         ams_id = normalize_am_unit_id(ams_id)
         ams_id = normalize_am_unit_id(ams_id)
         # AMS-HT (n3s, id 128-135): single tray, presence bit at 16+(ams_id-128).
         # AMS-HT (n3s, id 128-135): single tray, presence bit at 16+(ams_id-128).
         # Regular AMS (and the A2L-Lite normalised to id 6): ams_id*4 + tray_id.
         # Regular AMS (and the A2L-Lite normalised to id 6): ams_id*4 + tray_id.
-        # Anything outside those ranges has no known bit layout — don't guess it.
-        is_ht = 128 <= ams_id <= 135
-        if not is_ht and not (0 <= ams_id <= 15):
+        # A rack AMS (unit 32): its global tray id 28 + slot.
+        # Anything outside those has no known bit layout — don't guess it.
+        kind = unit_ams_type(ams_id, ams_unit.get("info"))
+        if tray_flag_bit(ams_id, 0, kind) is None:
             continue
             continue
         for tray in ams_unit.get("tray", []):
         for tray in ams_unit.get("tray", []):
             if not isinstance(tray, dict):
             if not isinstance(tray, dict):
@@ -392,7 +516,9 @@ def apply_tray_exist_bits(
                 continue
                 continue
             if not isinstance(tray_id, int):
             if not isinstance(tray_id, int):
                 continue
                 continue
-            global_bit = (16 + (ams_id - 128)) if is_ht else (ams_id * 4 + tray_id)
+            global_bit = tray_flag_bit(ams_id, tray_id, kind)
+            if global_bit is None or global_bit < 0:
+                continue
             slot_exists = (tray_exist_bits >> global_bit) & 1
             slot_exists = (tray_exist_bits >> global_bit) & 1
             if annotate_exists:
             if annotate_exists:
                 tray["exists"] = bool(slot_exists)
                 tray["exists"] = bool(slot_exists)
@@ -1652,6 +1778,11 @@ class BambuMQTTClient:
         # See normalize_am_unit_id / a2l_lite_wire_ids and memory a2l-am-unit-16.
         # See normalize_am_unit_id / a2l_lite_wire_ids and memory a2l-am-unit-16.
         self._has_a2l_am_unit: bool = False
         self._has_a2l_am_unit: bool = False
 
 
+        # Rack AMS units already logged as detected (once per unit).
+        self._rack_ams_logged: set[int] = set()
+        self._tray_layout_key: tuple | None = None
+        self._tray_layout = TrayLayout()
+
         # Why the last connection attempt was refused by the printer, or None
         # Why the last connection attempt was refused by the printer, or None
         # when we have never seen a CONNACK failure since the last success.
         # when we have never seen a CONNACK failure since the last success.
         # Without this a rejected access code was completely invisible: paho
         # Without this a rejected access code was completely invisible: paho
@@ -2375,6 +2506,7 @@ class BambuMQTTClient:
             print_data.get("ams_mapping"),
             print_data.get("ams_mapping"),
             print_data.get("ams_mapping2"),
             print_data.get("ams_mapping2"),
             self._is_dual_nozzle or is_dual_nozzle_model(self.model),
             self._is_dual_nozzle or is_dual_nozzle_model(self.model),
+            self.tray_layout,
         )
         )
 
 
     @staticmethod
     @staticmethod
@@ -2593,6 +2725,8 @@ class BambuMQTTClient:
                     # external slot with id=255 in vir_slot, but tray_now=254 when active.
                     # external slot with id=255 in vir_slot, but tray_now=254 when active.
                     # Remap id=255→254 for single-slot printers so active detection works.
                     # Remap id=255→254 for single-slot printers so active detection works.
                     # Dual-nozzle (H2D) has 2 slots: id=254 (Ext-L) and id=255 (Ext-R).
                     # Dual-nozzle (H2D) has 2 slots: id=254 (Ext-L) and id=255 (Ext-R).
+                    # A nozzle-rack printer (H3C) reports one input per hotend,
+                    # 247-255, kept as reported.
                     if len(vir_slot) == 1 and str(vir_slot[0].get("id", "")) == "255":
                     if len(vir_slot) == 1 and str(vir_slot[0].get("id", "")) == "255":
                         vir_slot[0]["id"] = "254"
                         vir_slot[0]["id"] = "254"
                     self.state.raw_data["vt_tray"] = vir_slot
                     self.state.raw_data["vt_tray"] = vir_slot
@@ -2847,6 +2981,8 @@ class BambuMQTTClient:
             if not isinstance(module, dict):
             if not isinstance(module, dict):
                 continue
                 continue
             name = module.get("name", "")
             name = module.get("name", "")
+            if name == AMS3H_MODULE_NAME:
+                name = f"{name}/{self._ams3h_unit_id()}"
             if not any(name.startswith(prefix) for prefix in _AMS_MODULE_PREFIXES):
             if not any(name.startswith(prefix) for prefix in _AMS_MODULE_PREFIXES):
                 continue
                 continue
             try:
             try:
@@ -3181,17 +3317,7 @@ class BambuMQTTClient:
         if not isinstance(mapping_raw, list) or not mapping_raw:
         if not isinstance(mapping_raw, list) or not mapping_raw:
             return None
             return None
 
 
-        candidates: dict[int, int] = {}  # global tray ID -> AMS unit
-        for value in mapping_raw:
-            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) == local_slot:
-                candidates[ams_hw_id * 4 + local_slot] = ams_hw_id
-            elif 128 <= ams_hw_id <= 135 and local_slot == 0:
-                candidates[ams_hw_id] = ams_hw_id
-
+        candidates = mapped_trays_with_slot(mapping_raw, local_slot)  # global tray ID -> AMS unit
         if len(candidates) > 1 and units:
         if len(candidates) > 1 and units:
             candidates = {tray: unit for tray, unit in candidates.items() if unit in units}
             candidates = {tray: unit for tray, unit in candidates.items() if unit in units}
         if len(candidates) == 1:
         if len(candidates) == 1:
@@ -3259,6 +3385,62 @@ class BambuMQTTClient:
                     )
                     )
                 self._has_a2l_am_unit = True
                 self._has_a2l_am_unit = True
 
 
+    @property
+    def tray_layout(self) -> TrayLayout:
+        """How this printer numbers its trays (utils.tray_ids); rebuilt only when its units change."""
+        raw = self.state.raw_data
+        units, vt_trays = raw.get("ams"), raw.get("vt_tray")
+        key = (
+            tuple((u.get("id"), u.get("info")) for u in units if isinstance(u, dict))
+            if isinstance(units, list)
+            else (),
+            tuple(vt.get("id") for vt in vt_trays if isinstance(vt, dict)) if isinstance(vt_trays, list) else (),
+            self.model,
+        )
+        if key != self._tray_layout_key:
+            from backend.app.utils.printer_models import has_ams3h
+
+            self._tray_layout = TrayLayout.from_state(self.state, has_ams3h(self.model))
+            self._tray_layout_key = key
+        return self._tray_layout
+
+    def _layout_with(self, ams_list: object) -> TrayLayout:
+        """``tray_layout`` including the units of a push not merged into the state yet."""
+        if not isinstance(ams_list, list) or not any(isinstance(u, dict) for u in ams_list):
+            return self.tray_layout
+        units = [u for u in ams_list if isinstance(u, dict)]
+        known = self.state.raw_data.get("ams")
+        if isinstance(known, list):
+            units.extend(u for u in known if isinstance(u, dict))
+        vt_trays = self.state.raw_data.get("vt_tray")
+        return TrayLayout.from_units(units, vt_trays if isinstance(vt_trays, list) else None)
+
+    def _note_rack_ams(self, layout: TrayLayout) -> None:
+        """Log a rack AMS the first time this connection sees it."""
+        for ams_id, kind in layout.rack_units:
+            if ams_id in self._rack_ams_logged:
+                continue
+            self._rack_ams_logged.add(ams_id)
+            logger.info(
+                "[%s] Rack AMS detected: unit %s (AMS type %s), global tray ids %s-%s",
+                self.serial_number,
+                ams_id,
+                int(kind),
+                global_tray_id(ams_id, 0, kind),
+                global_tray_id(ams_id, RACK_SLOT_COUNT - 1, kind),
+            )
+
+    def _ams3h_unit_id(self) -> int:
+        """The unit id of this printer's AMS 3H (32 on the H3C)."""
+        return next((uid for uid, kind in self.tray_layout.rack_units if kind == AmsType.ZMULTI_A), AMS3H_UNIT_ID)
+
+    def _rack_ams_type(self, ams_id: int) -> AmsType | None:
+        """The type of unit ``ams_id`` when it is one of this printer's rack AMS units, else None."""
+        for uid, kind in self.tray_layout.rack_units:
+            if uid == ams_id:
+                return kind
+        return None
+
     def _parse_fila_switch(self, data: dict) -> None:
     def _parse_fila_switch(self, data: dict) -> None:
         """Read the Filament Track Switch block out of a print payload — #1162.
         """Read the Filament Track Switch block out of a print payload — #1162.
 
 
@@ -3323,37 +3505,15 @@ class BambuMQTTClient:
                 continue
                 continue
             if ext_id <= 1:
             if ext_id <= 1:
                 snow_read.add(ext_id)
                 snow_read.add(ext_id)
-                # Normalize H2D snow value to global tray ID
-                ams_id = snow >> 8
-                slot = snow & 0xFF
-                if 0 <= ams_id <= 3:
-                    # Regular AMS slot
-                    global_tray = ams_id * 4 + (slot & 0x03)
-                    old_val = self.state.h2d_extruder_snow.get(ext_id)
-                    if old_val != global_tray:
-                        logger.debug(
-                            f"[{self.serial_number}] H2D extruder[{ext_id}] snow: "
-                            f"raw={snow} (AMS {ams_id} slot {slot}) -> global tray {global_tray}"
-                        )
-                    self.state.h2d_extruder_snow[ext_id] = global_tray
-                elif ams_id == 254 or ams_id == 255:
-                    # External spool or unloaded
-                    normalized = 254 if slot != 255 else 255
-                    old_val = self.state.h2d_extruder_snow.get(ext_id)
-                    if old_val != normalized:
-                        logger.debug(
-                            f"[{self.serial_number}] H2D extruder[{ext_id}] snow: "
-                            f"raw={snow} -> {'external' if normalized == 254 else 'unloaded'}"
-                        )
-                    self.state.h2d_extruder_snow[ext_id] = normalized
-                elif 128 <= ams_id <= 135:
-                    # External spool with hub mapping
-                    old_val = self.state.h2d_extruder_snow.get(ext_id)
-                    if old_val != ams_id:
-                        logger.debug(
-                            f"[{self.serial_number}] H2D extruder[{ext_id}] snow: raw={snow} -> external hub {ams_id}"
-                        )
-                    self.state.h2d_extruder_snow[ext_id] = ams_id
+                # Normalize H2D snow value to global tray ID (255 = unloaded)
+                global_tray = 255 if snow == 0xFFFF else decode_wire_slot(snow)
+                if global_tray is None:
+                    continue
+                if self.state.h2d_extruder_snow.get(ext_id) != global_tray:
+                    logger.debug(
+                        f"[{self.serial_number}] H2D extruder[{ext_id}] snow: raw={snow} -> global tray {global_tray}"
+                    )
+                self.state.h2d_extruder_snow[ext_id] = global_tray
         return self.state.active_extruder in snow_read
         return self.state.active_extruder in snow_read
 
 
     def _parse_extruder_slots(self, data: dict) -> None:
     def _parse_extruder_slots(self, data: dict) -> None:
@@ -3409,6 +3569,7 @@ class BambuMQTTClient:
             if "ams" in ams_data:
             if "ams" in ams_data:
                 ams_list = ams_data["ams"]
                 ams_list = ams_data["ams"]
                 self._normalize_a2l_am_units(ams_list)
                 self._normalize_a2l_am_units(ams_list)
+                apply_rack_ams_zone_fields(ams_list, dict(self.tray_layout.units))
             # Log all AMS dict fields to debug tray_now for H2D dual-nozzle
             # Log all AMS dict fields to debug tray_now for H2D dual-nozzle
             non_list_fields = {k: v for k, v in ams_data.items() if k != "ams"}
             non_list_fields = {k: v for k, v in ams_data.items() if k != "ams"}
             if non_list_fields:
             if non_list_fields:
@@ -3490,7 +3651,16 @@ class BambuMQTTClient:
                 # Single-nozzle printers with multiple AMS (e.g. P2S) also report local slot IDs (#420)
                 # Single-nozzle printers with multiple AMS (e.g. P2S) also report local slot IDs (#420)
                 # — disambiguated below using MQTT mapping field
                 # — disambiguated below using MQTT mapping field
                 ams_map = self.state.ams_extruder_map
                 ams_map = self.state.ams_extruder_map
-                if self._is_dual_nozzle and 0 <= parsed_tray_now <= 3:
+                # A rack AMS reports tray_now as its raw slot 0-8 (global 28 +
+                # slot). Its units may arrive in this same push, before the merge.
+                rack_layout = self._layout_with(ams_list)
+                self._note_rack_ams(rack_layout)
+                if rack_layout.rack_units and not self._is_dual_nozzle:
+                    rack_tray = rack_layout.raw_slot_to_global(parsed_tray_now)
+                    # 255 (nothing on the toolhead, also mid-swap) and any
+                    # external input id are kept as reported.
+                    self.state.tray_now = rack_tray if rack_tray is not None else parsed_tray_now
+                elif self._is_dual_nozzle and 0 <= parsed_tray_now <= 3:
                     # First, check if we have a pending target that matches this slot
                     # First, check if we have a pending target that matches this slot
                     pending_target = self.state.pending_tray_target
                     pending_target = self.state.pending_tray_target
                     pending_at = self.state.pending_tray_target_at
                     pending_at = self.state.pending_tray_target_at
@@ -3509,7 +3679,7 @@ class BambuMQTTClient:
                     if pending_target is not None:
                     if pending_target is not None:
                         # Same slot rule as the snow branch below: an AMS-HT
                         # Same slot rule as the snow branch below: an AMS-HT
                         # (128-135) has its one tray in slot 0.
                         # (128-135) has its one tray in slot 0.
-                        pending_slot = 0 if 128 <= pending_target <= 135 else pending_target % 4
+                        pending_slot = 0 if is_ams_ht_id(pending_target) else pending_target % 4
                         if pending_slot == parsed_tray_now:
                         if pending_slot == parsed_tray_now:
                             # Slot matches our pending target - use the full global ID
                             # Slot matches our pending target - use the full global ID
                             logger.debug(
                             logger.debug(
@@ -3541,7 +3711,7 @@ class BambuMQTTClient:
                             if snow_tray is None
                             if snow_tray is None
                             else snow_tray % 4
                             else snow_tray % 4
                             if snow_tray < 128
                             if snow_tray < 128
-                            else (0 if snow_tray <= 135 else -1)
+                            else (0 if is_ams_ht_id(snow_tray) else -1)
                         )
                         )
                         # A snow from an earlier frame may predate a swap: it
                         # A snow from an earlier frame may predate a swap: it
                         # names the tray only while it agrees with the slot.
                         # names the tray only while it agrees with the slot.
@@ -3600,26 +3770,20 @@ class BambuMQTTClient:
                             elif len(ams_on_extruder) == 1:
                             elif len(ams_on_extruder) == 1:
                                 # Single AMS on this extruder - unambiguous
                                 # Single AMS on this extruder - unambiguous
                                 active_ams_id = ams_on_extruder[0]
                                 active_ams_id = ams_on_extruder[0]
-                                if 128 <= active_ams_id <= 135:
-                                    # AMS-HT: single slot per unit, global ID = unit ID
-                                    global_tray_id = active_ams_id
-                                else:
-                                    global_tray_id = active_ams_id * 4 + parsed_tray_now
+                                # AMS-HT: single slot per unit, global ID = unit ID
+                                fallback_tray = global_tray_id(active_ams_id, parsed_tray_now)
                                 logger.debug(
                                 logger.debug(
                                     f"[{self.serial_number}] H2D tray_now fallback: "
                                     f"[{self.serial_number}] H2D tray_now fallback: "
-                                    f"slot {parsed_tray_now} + single AMS {active_ams_id} -> global ID {global_tray_id}"
+                                    f"slot {parsed_tray_now} + single AMS {active_ams_id} -> global ID {fallback_tray}"
                                 )
                                 )
-                                self.state.tray_now = global_tray_id
+                                self.state.tray_now = fallback_tray
                             elif len(ams_on_extruder) > 1:
                             elif len(ams_on_extruder) > 1:
                                 # Multiple AMS on this extruder - keep current if valid, else try to narrow down
                                 # Multiple AMS on this extruder - keep current if valid, else try to narrow down
                                 current_tray = self.state.tray_now
                                 current_tray = self.state.tray_now
                                 # Determine which AMS unit and slot the current tray belongs to
                                 # Determine which AMS unit and slot the current tray belongs to
-                                if 0 <= current_tray <= 15:
-                                    current_ams = current_tray // 4
-                                    current_slot = current_tray % 4
-                                elif 128 <= current_tray <= 135:
-                                    current_ams = current_tray  # AMS-HT: ID = tray ID
-                                    current_slot = 0
+                                if 0 <= current_tray <= 15 or is_ams_ht_id(current_tray):
+                                    # AMS-HT: ID = tray ID, one slot
+                                    current_ams, current_slot = rack_layout.slot_key(current_tray)
                                 else:
                                 else:
                                     current_ams = -1
                                     current_ams = -1
                                     current_slot = -1
                                     current_slot = -1
@@ -3637,7 +3801,7 @@ class BambuMQTTClient:
                                         candidates = ams_on_extruder
                                         candidates = ams_on_extruder
                                     if len(candidates) == 1:
                                     if len(candidates) == 1:
                                         cand = candidates[0]
                                         cand = candidates[0]
-                                        resolved = cand if 128 <= cand <= 135 else cand * 4 + parsed_tray_now
+                                        resolved = global_tray_id(cand, parsed_tray_now)
                                         logger.debug(
                                         logger.debug(
                                             f"[{self.serial_number}] H2D tray_now: multiple AMS {ams_on_extruder}, "
                                             f"[{self.serial_number}] H2D tray_now: multiple AMS {ams_on_extruder}, "
                                             f"narrowed to AMS {cand} -> global ID {resolved}"
                                             f"narrowed to AMS {cand} -> global ID {resolved}"
@@ -3740,15 +3904,8 @@ class BambuMQTTClient:
                     self.state.tray_now = parsed_tray_now
                     self.state.tray_now = parsed_tray_now
 
 
                 # Track last valid tray for usage tracking (survives retract → 255 at print end)
                 # Track last valid tray for usage tracking (survives retract → 255 at print end)
-                # Valid physical trays: 0-15 (regular AMS), 24-27 (A2L AMS-Lite,
-                # normalised unit 6), 128-135 (AMS-HT), 254 (external spool)
                 tn = self.state.tray_now
                 tn = self.state.tray_now
-                if (
-                    (0 <= tn <= 15)
-                    or (A2L_LITE_GLOBAL_BASE <= tn <= A2L_LITE_GLOBAL_BASE + 3)
-                    or (128 <= tn <= 135)
-                    or tn == 254
-                ):
+                if rack_layout.is_loaded_tray(tn):
                     # Log tray change for mid-print usage splitting. Gate on the
                     # Log tray change for mid-print usage splitting. Gate on the
                     # print-lifecycle flags (`_was_running` set on first RUNNING /
                     # print-lifecycle flags (`_was_running` set on first RUNNING /
                     # new print, `_completion_triggered` set when on_print_complete
                     # new print, `_completion_triggered` set when on_print_complete
@@ -3787,6 +3944,7 @@ class BambuMQTTClient:
         elif isinstance(ams_data, list):
         elif isinstance(ams_data, list):
             ams_list = ams_data
             ams_list = ams_data
             self._normalize_a2l_am_units(ams_list)
             self._normalize_a2l_am_units(ams_list)
+            apply_rack_ams_zone_fields(ams_list, dict(self.tray_layout.units))
         else:
         else:
             logger.warning("[%s] Unexpected AMS data format: %s", self.serial_number, type(ams_data))
             logger.warning("[%s] Unexpected AMS data format: %s", self.serial_number, type(ams_data))
             return
             return
@@ -3984,9 +4142,14 @@ class BambuMQTTClient:
         inlet_moves: list[tuple[int, str]] = []
         inlet_moves: list[tuple[int, str]] = []
         ams_extruder_map = dict(self.state.ams_extruder_map) if self.state.ams_extruder_map else {}
         ams_extruder_map = dict(self.state.ams_extruder_map) if self.state.ams_extruder_map else {}
         ams_switch_inlet = dict(self.state.ams_switch_inlet) if self.state.ams_switch_inlet else {}
         ams_switch_inlet = dict(self.state.ams_switch_inlet) if self.state.ams_switch_inlet else {}
+        # A rack AMS feeds the one extruder of a rack printer; listed here it
+        # would make the slot/dispatch code assume two nozzles.
+        rack_ams_ids = {uid for uid, _ in TrayLayout.from_units(merged_ams).rack_units}
         for ams_unit in merged_ams:
         for ams_unit in merged_ams:
             ams_id = ams_unit.get("id")
             ams_id = ams_unit.get("id")
             info = ams_unit.get("info")
             info = ams_unit.get("info")
+            if _mqtt_int(ams_id) in rack_ams_ids:
+                continue
             if ams_id is not None and info is not None:
             if ams_id is not None and info is not None:
                 try:
                 try:
                     # info is a hex-encoded string in MQTT JSON (e.g. "10001003")
                     # info is a hex-encoded string in MQTT JSON (e.g. "10001003")
@@ -4332,7 +4495,7 @@ class BambuMQTTClient:
         """
         """
         raw = self.state.raw_data or {}
         raw = self.state.raw_data or {}
         if ams_id == 255:
         if ams_id == 255:
-            want_ext = 254 + tray_id
+            want_ext = external_tray_id(tray_id)
             for vt in raw.get("vt_tray", []) or []:
             for vt in raw.get("vt_tray", []) or []:
                 if isinstance(vt, dict) and str(vt.get("id")) == str(want_ext):
                 if isinstance(vt, dict) and str(vt.get("id")) == str(want_ext):
                     return vt
                     return vt
@@ -5705,6 +5868,8 @@ class BambuMQTTClient:
                                 # H2C uses "fila_id", H2D uses "filament_id"
                                 # H2C uses "fila_id", H2D uses "filament_id"
                                 "filament_id": str(n.get("filament_id") or n.get("fila_id", "")),
                                 "filament_id": str(n.get("filament_id") or n.get("fila_id", "")),
                                 "filament_type": str(n.get("tray_type", "") or n.get("filament_type", "")),
                                 "filament_type": str(n.get("tray_type", "") or n.get("filament_type", "")),
+                                # H3C: home rack position 0-8 (the mounted hotend reports id 0)
+                                "addr_id": n.get("addr_id") if isinstance(n.get("addr_id"), int) else None,
                             }
                             }
                             for i, n in enumerate(nozzle_info)
                             for i, n in enumerate(nozzle_info)
                         ],
                         ],
@@ -5917,12 +6082,7 @@ class BambuMQTTClient:
             # Seeded with the assigned spools by usage_tracker.on_print_start.
             # Seeded with the assigned spools by usage_tracker.on_print_start.
             self.state.spool_change_log = []
             self.state.spool_change_log = []
             tn = self.state.tray_now
             tn = self.state.tray_now
-            if (
-                (0 <= tn <= 15)
-                or (A2L_LITE_GLOBAL_BASE <= tn <= A2L_LITE_GLOBAL_BASE + 3)
-                or (128 <= tn <= 135)
-                or tn == 254
-            ):
+            if self.tray_layout.is_loaded_tray(tn):
                 self.state.tray_change_log.append((tn, 0))
                 self.state.tray_change_log.append((tn, 0))
             # Initialize timelapse tracking based on current state
             # Initialize timelapse tracking based on current state
             # NOTE: xcam data is parsed BEFORE this code runs in _process_message,
             # NOTE: xcam data is parsed BEFORE this code runs in _process_message,
@@ -6052,6 +6212,11 @@ class BambuMQTTClient:
             self._timelapse_during_print = False  # Reset for next print
             self._timelapse_during_print = False  # Reset for next print
             # Include HMS errors for failure reason detection
             # Include HMS errors for failure reason detection
             hms_errors_data = [e.completion_payload() for e in self.state.hms_errors or []]
             hms_errors_data = [e.completion_payload() for e in self.state.hms_errors or []]
+            from backend.app.utils.printer_models import usage_progress
+
+            last_progress = usage_progress(
+                self.model, self._last_valid_progress, self._last_valid_layer_num, self.state.total_layers
+            )
             self.on_print_complete(
             self.on_print_complete(
                 {
                 {
                     "status": status,
                     "status": status,
@@ -6063,7 +6228,7 @@ class BambuMQTTClient:
                     "hms_errors": hms_errors_data,
                     "hms_errors": hms_errors_data,
                     "ams_mapping": self._captured_ams_mapping,
                     "ams_mapping": self._captured_ams_mapping,
                     # Last valid progress/layer before firmware reset (for partial usage tracking)
                     # Last valid progress/layer before firmware reset (for partial usage tracking)
-                    "last_progress": self._last_valid_progress,
+                    "last_progress": last_progress,
                     "last_layer_num": self._last_valid_layer_num,
                     "last_layer_num": self._last_valid_layer_num,
                 }
                 }
             )
             )
@@ -6431,14 +6596,26 @@ class BambuMQTTClient:
             # Passing raw 254/255 in the flat array causes H2D firmware to fail
             # Passing raw 254/255 in the flat array causes H2D firmware to fail
             # with 0700_8012 "Failed to get AMS mapping table".
             # with 0700_8012 "Failed to get AMS mapping table".
             flat_ams_mapping = []
             flat_ams_mapping = []
+            layout = self.tray_layout
             if ams_mapping is not None:
             if ams_mapping is not None:
                 for tray_id in ams_mapping:
                 for tray_id in ams_mapping:
                     # Ensure tray_id is an integer (may be string from JSON)
                     # Ensure tray_id is an integer (may be string from JSON)
                     tray_id = int(tray_id) if tray_id is not None else -1
                     tray_id = int(tray_id) if tray_id is not None else -1
+                    rack_slot = layout.rack_slot(tray_id) if tray_id >= 0 else None
                     if tray_id == -1:
                     if tray_id == -1:
                         # Unmapped filament slot
                         # Unmapped filament slot
                         flat_ams_mapping.append(-1)
                         flat_ams_mapping.append(-1)
                         ams_mapping2.append({"ams_id": 255, "slot_id": 255})
                         ams_mapping2.append({"ams_id": 255, "slot_id": 255})
+                    elif layout.rack and layout.is_external(tray_id):
+                        # A rack printer's per-hotend input 247-255, as Studio
+                        # sends it: -1 flat, its own id in ams_mapping2.
+                        flat_ams_mapping.append(-1)
+                        ams_mapping2.append({"ams_id": tray_id, "slot_id": 0})
+                    elif rack_slot is not None:
+                        # Rack AMS slot, as Studio sends it: 28 + k flat,
+                        # {ams_id: 32, slot_id: k} in ams_mapping2.
+                        flat_ams_mapping.append(tray_id)
+                        ams_mapping2.append({"ams_id": rack_slot[0], "slot_id": rack_slot[1]})
                     elif tray_id >= 254:
                     elif tray_id >= 254:
                         # External/virtual spool. BambuStudio convention:
                         # External/virtual spool. BambuStudio convention:
                         #   255 = VIRTUAL_TRAY_MAIN_ID (main/right nozzle)
                         #   255 = VIRTUAL_TRAY_MAIN_ID (main/right nozzle)
@@ -6457,7 +6634,7 @@ class BambuMQTTClient:
                         # AMS-HT: global tray ID IS the ams_id (single tray per unit)
                         # AMS-HT: global tray ID IS the ams_id (single tray per unit)
                         flat_ams_mapping.append(tray_id)
                         flat_ams_mapping.append(tray_id)
                         ams_mapping2.append({"ams_id": tray_id, "slot_id": 0})
                         ams_mapping2.append({"ams_id": tray_id, "slot_id": 0})
-                    elif (_a2l := a2l_lite_wire_ids(tray_id // 4, tray_id)) is not None:
+                    elif (_a2l := a2l_lite_wire_ids(layout.slot_key(tray_id)[0], tray_id)) is not None:
                         # A2L AMS-Lite (normalised global 24-27): flat mapping is the
                         # A2L AMS-Lite (normalised global 24-27): flat mapping is the
                         # LOCAL slot 0-3 and ams_mapping2 carries {ams_id:16, slot_id:0-3}
                         # LOCAL slot 0-3 and ams_mapping2 carries {ams_id:16, slot_id:0-3}
                         # — both CONFIRMED against the firmware's own mapping
                         # — both CONFIRMED against the firmware's own mapping
@@ -6467,8 +6644,7 @@ class BambuMQTTClient:
                         ams_mapping2.append({"ams_id": _wire_ams, "slot_id": _wire_slot})
                         ams_mapping2.append({"ams_id": _wire_ams, "slot_id": _wire_slot})
                     else:
                     else:
                         # Regular AMS tray: Global tray ID = (ams_id * 4) + slot_id
                         # Regular AMS tray: Global tray ID = (ams_id * 4) + slot_id
-                        ams_id = tray_id // 4
-                        slot_id = tray_id % 4
+                        ams_id, slot_id = layout.slot_key(tray_id)
                         flat_ams_mapping.append(tray_id)
                         flat_ams_mapping.append(tray_id)
                         ams_mapping2.append({"ams_id": ams_id, "slot_id": slot_id})
                         ams_mapping2.append({"ams_id": ams_id, "slot_id": slot_id})
 
 
@@ -6503,8 +6679,10 @@ class BambuMQTTClient:
             #     (or is recomputed upstream) instead of silently going external, and
             #     (or is recomputed upstream) instead of silently going external, and
             #     never gets force-enabled by (a) either.
             #     never gets force-enabled by (a) either.
             if ams_mapping and not is_dual_nozzle:
             if ams_mapping and not is_dual_nozzle:
-                has_real_tray = any(t is not None and 0 <= int(t) <= 253 for t in ams_mapping)
-                all_external = all(t is None or int(t) >= 254 for t in ams_mapping)
+                has_real_tray = any(
+                    t is not None and 0 <= int(t) <= 253 and not layout.is_external(int(t)) for t in ams_mapping
+                )
+                all_external = all(t is None or int(t) >= 254 or layout.is_external(int(t)) for t in ams_mapping)
                 # (Not when the scheduler worked out from the plate's own
                 # (Not when the scheduler worked out from the plate's own
                 # filament list that it prints only from the spool holder: a
                 # filament list that it prints only from the spool holder: a
                 # real tray here is then one another plate of the file uses.)
                 # real tray here is then one another plate of the file uses.)
@@ -7026,6 +7204,11 @@ class BambuMQTTClient:
             logger.exception("[%s] publish_raw failed for topic=%s", self.serial_number, topic)
             logger.exception("[%s] publish_raw failed for topic=%s", self.serial_number, topic)
             return False
             return False
 
 
+    def _ams3h_drying_zone_id(self, ams_id: int) -> int:
+        """The drying zone id of AMS 3H unit ``ams_id`` from the last status, else 0."""
+        unit = self._ams_unit(ams_id)
+        return _mqtt_int((unit or {}).get("dry_zone_id")) or 0
+
     def send_drying_command(
     def send_drying_command(
         self, ams_id: int, temp: int, duration: int, mode: int = 1, filament: str = "", rotate_tray: bool = False
         self, ams_id: int, temp: int, duration: int, mode: int = 1, filament: str = "", rotate_tray: bool = False
     ):
     ):
@@ -7047,6 +7230,7 @@ class BambuMQTTClient:
         # _drying_targets dict below stays keyed by the normalised id so the
         # _drying_targets dict below stays keyed by the normalised id so the
         # on_drying_complete callback matches the telemetry.
         # on_drying_complete callback matches the telemetry.
         wire_ams_id = a2l_lite_wire_ids(ams_id, 0)[0] if ams_id == A2L_LITE_NORMALIZED_AMS_ID else ams_id
         wire_ams_id = a2l_lite_wire_ids(ams_id, 0)[0] if ams_id == A2L_LITE_NORMALIZED_AMS_ID else ams_id
+        ams3h = self._rack_ams_type(ams_id) == AmsType.ZMULTI_A
         command = {
         command = {
             "print": {
             "print": {
                 "sequence_id": str(self._sequence_id),
                 "sequence_id": str(self._sequence_id),
@@ -7062,6 +7246,14 @@ class BambuMQTTClient:
                 "close_power_conflict": False,
                 "close_power_conflict": False,
             }
             }
         }
         }
+        if ams3h:
+            # As Studio 02.09 sends it (DevFilaSystemCtrl.cpp): a start names
+            # the drying zone and cooling_temp; a stop names no zone. The AMS 3H
+            # can't turn spools.
+            command["print"]["rotate_tray"] = False
+            if mode == 1:
+                command["print"]["zone_id"] = self._ams3h_drying_zone_id(ams_id)
+                command["print"]["cooling_temp"] = ams3h_drying_cooling_temp(filament)
         # Log the full wire JSON at INFO so support bundles capture exactly
         # Log the full wire JSON at INFO so support bundles capture exactly
         # what we sent — needed to diagnose silent rejections (#1447) where
         # what we sent — needed to diagnose silent rejections (#1447) where
         # the printer ACKs the command but never starts/stops drying.
         # the printer ACKs the command but never starts/stops drying.
@@ -7804,6 +7996,16 @@ class BambuMQTTClient:
         Returns:
         Returns:
             True if command was sent, False otherwise
             True if command was sent, False otherwise
         """
         """
+        # The H3C's heater only runs in the airduct's heating mode.
+        from backend.app.utils.printer_models import chamber_heater_needs_heating_mode
+
+        if target > 0 and self.state.airduct_mode != 1 and chamber_heater_needs_heating_mode(self.model):
+            logger.info(
+                "[%s] Chamber target %s°C: switching the airduct to heating mode first (H3C)",
+                self.serial_number,
+                target,
+            )
+            self.set_airduct_mode("heating")
         # M141 sets chamber temperature
         # M141 sets chamber temperature
         result = self.send_gcode(f"M141 S{target}")
         result = self.send_gcode(f"M141 S{target}")
         # Track chamber target locally (MQTT reports encoded values that need filtering)
         # Track chamber target locally (MQTT reports encoded values that need filtering)
@@ -7953,6 +8155,87 @@ class BambuMQTTClient:
         )
         )
         return True
         return True
 
 
+    def _ams_unit(self, ams_id: int) -> dict | None:
+        """AMS unit ``ams_id`` in the last status, or None."""
+        for unit in self.state.raw_data.get("ams") or []:
+            if isinstance(unit, dict) and _mqtt_int(unit.get("id")) == ams_id:
+                return unit
+        return None
+
+    def _rack_tray(self, tray_id: int | None) -> tuple[int, int, dict | None] | None:
+        """``(ams_id, slot, tray)`` of a rack AMS slot's global tray id (the AMS 3H's 28-36), else None."""
+        if not isinstance(tray_id, int):
+            return None
+        located = self.tray_layout.rack_slot(tray_id)
+        if located is None:
+            return None
+        ams_id, slot = located
+        unit = self._ams_unit(ams_id) or {}
+        tray = next(
+            (t for t in unit.get("tray") or [] if isinstance(t, dict) and _mqtt_int(t.get("id")) == slot),
+            None,
+        )
+        return ams_id, slot, tray
+
+    def rack_tray_has_filament(self, tray_id: int) -> bool:
+        """Whether rack AMS slot ``tray_id`` reports a filament type, which Studio requires before a Load.
+
+        The AMS 3H registers a spool only once it has pulled the filament in at the inlet.
+        """
+        rack = self._rack_tray(tray_id)
+        return bool(rack and rack[2] and rack[2].get("tray_type"))
+
+    @staticmethod
+    def _tray_mid_temp(tray: dict | None) -> int:
+        """Midpoint of a tray's nozzle_temp_min/max, or -1 (as Studio's ams_change_filament)."""
+        if tray is None:
+            return -1
+        low, high = _mqtt_int(tray.get("nozzle_temp_min")), _mqtt_int(tray.get("nozzle_temp_max"))
+        return (low + high) // 2 if low and high and low > 0 and high > 0 else -1
+
+    def _rack_change_filament(self, tray_id: int, load: bool) -> bool:
+        """Load AMS 3H slot ``tray_id`` into its own hotend, or unload that hotend.
+
+        As Studio 02.09 sends it for a direct-connected rack (PageFilament.cpp):
+        load targets 32 at slot k, unload 255 at slot 255 - k (the feeder to
+        retract). The AMS 3H can't rewind: an unload ends with a hand pull.
+        """
+        client = self._client
+        rack = self._rack_tray(tray_id)
+        if client is None or rack is None:
+            return False
+        ams_id, slot, tray = rack
+        on_head = self._rack_tray(self.state.tray_now)
+        self._sequence_id += 1
+        command = {
+            "print": {
+                "command": "ams_change_filament",
+                "sequence_id": str(self._sequence_id),
+                "ams_id": ams_id,
+                "target": ams_id if load else 255,
+                "slot_id": slot if load else 255 - slot,
+                "extruder_id": 0,
+                "curr_temp": self._tray_mid_temp(on_head[2] if on_head else None),
+                "tar_temp": self._tray_mid_temp(tray),
+            }
+        }
+        command_json = json.dumps(command)
+        logger.info(
+            "[%s] Publishing ams_change_filament (%s, AMS 3H slot %d): %s",
+            self.serial_number,
+            "load" if load else "unload",
+            slot + 1,
+            command_json,
+        )
+        client.publish(self.topic_publish, command_json, qos=1)
+        if load:
+            self.state.pending_tray_target = tray_id
+            self.state.pending_tray_target_at = time.monotonic()
+        else:
+            self.state.pending_tray_target = None
+            self.state.pending_tray_target_at = None
+        return True
+
     def ams_load_filament(self, tray_id: int, extruder_id: int | None = None) -> bool:
     def ams_load_filament(self, tray_id: int, extruder_id: int | None = None) -> bool:
         """Load filament from a specific AMS tray.
         """Load filament from a specific AMS tray.
 
 
@@ -7978,6 +8261,17 @@ class BambuMQTTClient:
         if not self._client or not self.state.connected:
         if not self._client or not self.state.connected:
             logger.warning("[%s] Cannot load filament: not connected", self.serial_number)
             logger.warning("[%s] Cannot load filament: not connected", self.serial_number)
             return False
             return False
+        if self.tray_layout.rack_units:
+            # Only an AMS 3H slot can be loaded: an external input or an AMS-B
+            # can't feed itself (Studio's CanAutoFeed).
+            rack = self._rack_tray(tray_id)
+            if rack is None or self.tray_layout.ams_type(rack[0]) != AmsType.ZMULTI_A:
+                logger.warning("[%s] Load: tray %s is not an AMS 3H slot", self.serial_number, tray_id)
+                return False
+            if not self.rack_tray_has_filament(tray_id):
+                logger.warning("[%s] Load: AMS 3H slot %d has no filament", self.serial_number, rack[1] + 1)
+                return False
+            return self._rack_change_filament(tray_id, load=True)
 
 
         # Build the ams_change_filament command. Encoding differs by target type:
         # Build the ams_change_filament command. Encoding differs by target type:
         #   - AMS slots (0..15, A2L 24..27, AMS-HT 128..135): see
         #   - AMS slots (0..15, A2L 24..27, AMS-HT 128..135): see
@@ -8005,7 +8299,7 @@ class BambuMQTTClient:
         else:
         else:
             # A regular AMS, the A2L AMS-Lite (physical unit 16) or an AMS-HT
             # A regular AMS, the A2L AMS-Lite (physical unit 16) or an AMS-HT
             # (128-135), as BambuStudio addresses each.
             # (128-135), as BambuStudio addresses each.
-            ams_id, slot_id, wire_target = change_filament_wire_ids(tray_id)
+            ams_id, slot_id, wire_target = change_filament_wire_ids(tray_id, self.tray_layout)
             curr_temp = -1
             curr_temp = -1
             tar_temp = -1
             tar_temp = -1
 
 
@@ -8058,6 +8352,13 @@ class BambuMQTTClient:
         if not self._client or not self.state.connected:
         if not self._client or not self.state.connected:
             logger.warning("[%s] Cannot unload filament: not connected", self.serial_number)
             logger.warning("[%s] Cannot unload filament: not connected", self.serial_number)
             return False
             return False
+        if self.tray_layout.rack_units:
+            # The hotend of the named AMS 3H slot, else of the one on the toolhead.
+            source = tray_id if tray_id is not None else self.state.tray_now
+            if self._rack_tray(source) is None:
+                logger.warning("[%s] Unload: no AMS 3H slot to unload (tray %s)", self.serial_number, source)
+                return False
+            return self._rack_change_filament(source, load=False)
 
 
         # Get the currently loaded tray info
         # Get the currently loaded tray info
         tray_now = self.state.tray_now
         tray_now = self.state.tray_now
@@ -8069,7 +8370,7 @@ class BambuMQTTClient:
             ams_id = 255  # No filament or external spool
             ams_id = 255  # No filament or external spool
         else:
         else:
             # Source AMS: the A2L AMS-Lite as physical 16, an AMS-HT as its unit id.
             # Source AMS: the A2L AMS-Lite as physical 16, an AMS-HT as its unit id.
-            ams_id = change_filament_wire_ids(source_tray)[0]
+            ams_id = change_filament_wire_ids(source_tray, self.tray_layout)[0]
 
 
         # Refuse an addressed unload of a slot no hotend is holding — but only on
         # Refuse an addressed unload of a slot no hotend is holding — but only on
         # a printer that has more than one hotend, which is the only case the
         # a printer that has more than one hotend, which is the only case the
@@ -8092,7 +8393,7 @@ class BambuMQTTClient:
         # The external spool is excluded for a different reason: 254/255 are not
         # The external spool is excluded for a different reason: 254/255 are not
         # ams*4+slot, so the local-slot arithmetic below cannot describe them.
         # ams*4+slot, so the local-slot arithmetic below cannot describe them.
         if tray_id is not None and tray_id not in (254, 255) and self._is_dual_nozzle and self.state.extruder_slots:
         if tray_id is not None and tray_id not in (254, 255) and self._is_dual_nozzle and self.state.extruder_slots:
-            local_slot = change_filament_wire_ids(tray_id)[1]
+            local_slot = change_filament_wire_ids(tray_id, self.tray_layout)[1]
             holder = next(
             holder = next(
                 (ext for ext, slot in self.state.extruder_slots.items() if slot.holds(ams_id, local_slot)),
                 (ext for ext, slot in self.state.extruder_slots.items() if slot.holds(ams_id, local_slot)),
                 None,
                 None,
@@ -8194,11 +8495,13 @@ class BambuMQTTClient:
         tray_now = self.state.tray_now
         tray_now = self.state.tray_now
         if tray_now != 255:
         if tray_now != 255:
             # Decode which tray is loaded for the message
             # Decode which tray is loaded for the message
+            layout = self.tray_layout
             if tray_now == 254:
             if tray_now == 254:
                 loaded_tray = "external spool"
                 loaded_tray = "external spool"
+            elif (rack := layout.rack_slot(tray_now)) is not None:
+                loaded_tray = f"{unit_label(rack[0], layout.ams_type(rack[0]))} slot {rack[1] + 1}"
             elif tray_now >= 0 and tray_now < 128:
             elif tray_now >= 0 and tray_now < 128:
-                loaded_ams = tray_now // 4
-                loaded_slot = tray_now % 4
+                loaded_ams, loaded_slot = layout.slot_key(tray_now)
                 loaded_tray = f"AMS {loaded_ams + 1} slot {loaded_slot + 1}"
                 loaded_tray = f"AMS {loaded_ams + 1} slot {loaded_slot + 1}"
             else:
             else:
                 loaded_tray = f"tray {tray_now}"
                 loaded_tray = f"tray {tray_now}"
@@ -8234,10 +8537,10 @@ class BambuMQTTClient:
             "[%s] ams_get_rfid refused (%s), falling back to M620 R%s",
             "[%s] ams_get_rfid refused (%s), falling back to M620 R%s",
             self.serial_number,
             self.serial_number,
             refused,
             refused,
-            wire_ams_id * 4 + wire_slot_id,
+            legacy_tray_index(wire_ams_id, wire_slot_id),
         )
         )
         legacy_refused = await self._send_rfid_command(
         legacy_refused = await self._send_rfid_command(
-            {"command": "gcode_line", "param": f"M620 R{wire_ams_id * 4 + wire_slot_id}\n"}
+            {"command": "gcode_line", "param": f"M620 R{legacy_tray_index(wire_ams_id, wire_slot_id)}\n"}
         )
         )
         if legacy_refused is None:
         if legacy_refused is None:
             return True, f"Refreshing AMS {ams_id} tray {tray_id} (legacy command)"
             return True, f"Refreshing AMS {ams_id} tray {tray_id} (legacy command)"
@@ -8325,7 +8628,7 @@ class BambuMQTTClient:
                 # (254=ext-L / slot 0, 255=ext-R / slot 1). The dual case is NOT
                 # (254=ext-L / slot 0, 255=ext-R / slot 1). The dual case is NOT
                 # covered by the X1C capture — left at `mqtt_tray_id = 0` until a
                 # covered by the X1C capture — left at `mqtt_tray_id = 0` until a
                 # captured Studio→H2D exchange confirms the correct value.
                 # captured Studio→H2D exchange confirms the correct value.
-                mqtt_ams_id = 254 + tray_id
+                mqtt_ams_id = external_tray_id(tray_id)
                 mqtt_tray_id = 0
                 mqtt_tray_id = 0
             else:
             else:
                 # Single external slot (X1C, P1S, A1): global tray_id=254.
                 # Single external slot (X1C, P1S, A1): global tray_id=254.
@@ -8337,6 +8640,10 @@ class BambuMQTTClient:
             # firmware's own ams_mapping2 {ams_id:16, slot_id:0-3}).
             # firmware's own ams_mapping2 {ams_id:16, slot_id:0-3}).
             mqtt_ams_id, slot_id, _ = _a2l
             mqtt_ams_id, slot_id, _ = _a2l
             mqtt_tray_id = slot_id
             mqtt_tray_id = slot_id
+        elif self._rack_ams_type(ams_id) is not None:
+            # AMS 3H: unit 32 with its slot 0-8 in tray_id and slot_id (verified).
+            mqtt_ams_id = ams_id
+            mqtt_tray_id = slot_id = tray_id
         elif ams_id <= 3:
         elif ams_id <= 3:
             mqtt_ams_id = ams_id
             mqtt_ams_id = ams_id
             mqtt_tray_id = tray_id
             mqtt_tray_id = tray_id
@@ -8398,7 +8705,7 @@ class BambuMQTTClient:
             vt_tray = self.state.raw_data.get("vt_tray", []) if self.state.raw_data else []
             vt_tray = self.state.raw_data.get("vt_tray", []) if self.state.raw_data else []
             if len(vt_tray) > 1:
             if len(vt_tray) > 1:
                 # Dual external slots (H2D): each ext slot is its own virtual AMS unit
                 # Dual external slots (H2D): each ext slot is its own virtual AMS unit
-                mqtt_ams_id = 254 + tray_id
+                mqtt_ams_id = external_tray_id(tray_id)
                 mqtt_tray_id = 0
                 mqtt_tray_id = 0
             else:
             else:
                 # Single external slot (X1C, P1S, A1): global tray_id=254.
                 # Single external slot (X1C, P1S, A1): global tray_id=254.
@@ -8409,6 +8716,10 @@ class BambuMQTTClient:
             # A2L AMS-Lite: physical unit 16, local 0-3 slot (matches ams_mapping2).
             # A2L AMS-Lite: physical unit 16, local 0-3 slot (matches ams_mapping2).
             mqtt_ams_id, slot_id, _ = _a2l
             mqtt_ams_id, slot_id, _ = _a2l
             mqtt_tray_id = slot_id
             mqtt_tray_id = slot_id
+        elif self._rack_ams_type(ams_id) is not None:
+            # AMS 3H: unit 32 with its slot 0-8 (see ams_set_filament_setting).
+            mqtt_ams_id = ams_id
+            mqtt_tray_id = slot_id = tray_id
         elif ams_id <= 3:
         elif ams_id <= 3:
             mqtt_ams_id = ams_id
             mqtt_ams_id = ams_id
             mqtt_tray_id = tray_id
             mqtt_tray_id = tray_id
@@ -8482,23 +8793,27 @@ class BambuMQTTClient:
             if len(vt_tray) > 1:
             if len(vt_tray) > 1:
                 # Dual external slots (H2D): each ext slot is its own virtual AMS unit
                 # Dual external slots (H2D): each ext slot is its own virtual AMS unit
                 # Confirmed from BambuStudio logs: ext-R sends ams_id=255, tray_id=255
                 # Confirmed from BambuStudio logs: ext-R sends ams_id=255, tray_id=255
-                mqtt_ams_id = 254 + tray_id
-                mqtt_tray_id = 254 + tray_id
+                mqtt_ams_id = external_tray_id(tray_id)
+                mqtt_tray_id = external_tray_id(tray_id)
             else:
             else:
                 # Single external slot (X1C, P1S, A1): global tray_id=254
                 # Single external slot (X1C, P1S, A1): global tray_id=254
                 mqtt_ams_id = 254
                 mqtt_ams_id = 254
                 mqtt_tray_id = 254
                 mqtt_tray_id = 254
             slot_id = 0
             slot_id = 0
+        elif self._rack_ams_type(ams_id) is not None:
+            # AMS 3H: unit 32, slot k in tray_id and slot_id ({32, 8, 8} verified).
+            mqtt_ams_id = ams_id
+            mqtt_tray_id = slot_id = tray_id
         elif ams_id <= 3:
         elif ams_id <= 3:
             mqtt_ams_id = ams_id
             mqtt_ams_id = ams_id
-            mqtt_tray_id = ams_id * 4 + tray_id
+            mqtt_tray_id = legacy_tray_index(ams_id, tray_id)
             slot_id = tray_id
             slot_id = tray_id
         elif (_a2l := a2l_lite_wire_ids(ams_id, tray_id)) is not None:
         elif (_a2l := a2l_lite_wire_ids(ams_id, tray_id)) is not None:
             # A2L AMS-Lite: physical unit 16 + local slot are confirmed; the GLOBAL
             # A2L AMS-Lite: physical unit 16 + local slot are confirmed; the GLOBAL
             # tray_id this command wants (physical 16*4+slot) is extrapolated (no
             # tray_id this command wants (physical 16*4+slot) is extrapolated (no
             # A2L cali_sel capture yet) — see a2l_lite_wire_ids.
             # A2L cali_sel capture yet) — see a2l_lite_wire_ids.
             mqtt_ams_id, slot_id, mqtt_tray_id = _a2l
             mqtt_ams_id, slot_id, mqtt_tray_id = _a2l
-        elif ams_id >= 128 and ams_id <= 135:
+        elif is_ams_ht_id(ams_id):
             mqtt_ams_id = ams_id
             mqtt_ams_id = ams_id
             mqtt_tray_id = tray_id
             mqtt_tray_id = tray_id
             slot_id = 0
             slot_id = 0
@@ -8571,7 +8886,10 @@ class BambuMQTTClient:
         # physical global (extrapolated 64-67; see a2l_lite_wire_ids). ams_id
         # physical global (extrapolated 64-67; see a2l_lite_wire_ids). ams_id
         # stays 0 (hardcoded, as for every other unit here).
         # stays 0 (hardcoded, as for every other unit here).
         wire_tray_id = tray_id
         wire_tray_id = tray_id
-        if 0 <= tray_id <= 253 and (_a2l := a2l_lite_wire_ids(tray_id // 4, tray_id)) is not None:
+        if (
+            0 <= tray_id <= 253
+            and (_a2l := a2l_lite_wire_ids(self.tray_layout.slot_key(tray_id)[0], tray_id)) is not None
+        ):
             wire_tray_id = _a2l[2]
             wire_tray_id = _a2l[2]
 
 
         filament_entry = {
         filament_entry = {

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

@@ -188,7 +188,7 @@ def rtsp_socket_timeout_flag() -> str:
 def supports_rtsp(model: str | None) -> bool:
 def supports_rtsp(model: str | None) -> bool:
     """Check if printer model supports RTSP camera streaming.
     """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
     Chamber image only: A1, A1MINI, P1P, P1S
 
 
     Note: Model can be either display name (e.g., "P2S") or internal code (e.g., "N7").
     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
       - O1S: H2S
       - O1E, O2D: H2D Pro
       - O1E, O2D: H2D Pro
       - N7: P2S
       - N7: P2S
+      - N10: H3C
     """
     """
     if model:
     if model:
         model_upper = model.upper()
         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
             return True
         # Internal codes for RTSP models
         # 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
             return True
     # A1/P1 and unknown models use chamber image protocol
     # A1/P1 and unknown models use chamber image protocol
     return False
     return False
@@ -217,7 +218,7 @@ def supports_rtsp(model: str | None) -> bool:
 def get_camera_port(model: str | None) -> int:
 def get_camera_port(model: str | None) -> int:
     """Get the camera port based on printer model.
     """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.
     A1/P1 series use chamber image protocol on port 6000.
     """
     """
     if supports_rtsp(model):
     if supports_rtsp(model):

+ 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.spool_assignment import SpoolAssignment
 from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 from backend.app.services.filament_requirements import extract_filament_requirements
 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.filament_ids import normalize_slicer_filament
+from backend.app.utils.tray_ids import TrayLayout
 
 
 logger = logging.getLogger(__name__)
 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:
 def _resolve_source_3mf(item: PrintQueueItem) -> Path | None:
     """Locate the 3MF file backing this queue item (archive or library).
     """Locate the 3MF file backing this queue item (archive or library).
 
 
@@ -296,6 +275,7 @@ def _live_tray_keys(printer_id: int) -> dict[int, str]:
     units = raw.get("ams") or []
     units = raw.get("ams") or []
     if isinstance(units, dict):  # also stored as the push's {"ams": [...]}
     if isinstance(units, dict):  # also stored as the push's {"ams": [...]}
         units = units.get("ams") or []
         units = units.get("ams") or []
+    layout = TrayLayout.from_state(state)
     for unit in units:
     for unit in units:
         if not isinstance(unit, dict):
         if not isinstance(unit, dict):
             continue
             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", ""))
                 ams_id, tray_id = int(unit.get("id", "")), int(tray.get("id", ""))
             except (TypeError, ValueError):
             except (TypeError, ValueError):
                 continue
                 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 []
     vt_trays = raw.get("vt_tray") or []
     for tray in [vt_trays] if isinstance(vt_trays, dict) else vt_trays:
     for tray in [vt_trays] if isinstance(vt_trays, dict) else vt_trays:
         if not isinstance(tray, dict):
         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.
     # so a status push in between cannot put them in different pools.
     if live_keys is None:
     if live_keys is None:
         live_keys = _live_tray_keys(printer_id)
         live_keys = _live_tray_keys(printer_id)
+    layout = printer_tray_layout(printer_id)
     materials: list[SlotMaterial] = []
     materials: list[SlotMaterial] = []
 
 
     def _append(
     def _append(
@@ -557,7 +538,7 @@ async def build_slot_materials(
         spool: SlotSpoolIdentity | None = None,
         spool: SlotSpoolIdentity | None = None,
         cost_per_kg: float | None = None,
         cost_per_kg: float | None = 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(
         materials.append(
             SlotMaterial(
             SlotMaterial(
                 ams_id=ams_id,
                 ams_id=ams_id,
@@ -733,6 +714,7 @@ async def compute_deficit_for_queue_item(
     spoolman_mode = await _is_spoolman_mode(db)
     spoolman_mode = await _is_spoolman_mode(db)
     backup_on, ams_extruder_map, is_dual = await _get_printer_backup_context(printer_id)
     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 {}
     live_keys = _live_tray_keys(printer_id) if backup_on else {}
+    layout = printer_tray_layout(printer_id)
 
 
     # ------------------------------------------------------------------ phase 1
     # ------------------------------------------------------------------ phase 1
     # Resolve each requirement to (ams_id, tray_id, identity, remaining_grams).
     # 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:
         if global_tray_id is None or global_tray_id < 0:
             _undetermined(req)
             _undetermined(req)
             continue
             continue
-        ams_id, tray_id = _global_to_ams_key(global_tray_id)
+        ams_id, tray_id = layout.slot_key(global_tray_id)
 
 
         identity = "attrs:|||"
         identity = "attrs:|||"
         remaining: float | None = None
         remaining: float | None = None

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

@@ -67,6 +67,7 @@ MODEL_TO_API_KEY = {
     "H2D": "h2d",
     "H2D": "h2d",
     "H2C": "h2c",
     "H2C": "h2c",
     "H2S": "h2s",
     "H2S": "h2s",
+    "H3C": "h3c",
     "P2S": "p2s",
     "P2S": "p2s",
     "X1E": "x1e",
     "X1E": "x1e",
     "X2D": "x2d",
     "X2D": "x2d",
@@ -91,6 +92,7 @@ MODEL_TO_API_KEY = {
     "N6": "x2d",
     "N6": "x2d",
     "N7": "p2s",
     "N7": "p2s",
     "N9": "a2l",
     "N9": "a2l",
+    "N10": "h3c",
 }
 }
 
 
 # Reverse mapping: API key to model codes
 # Reverse mapping: API key to model codes
@@ -107,6 +109,7 @@ API_KEY_TO_DEV_MODEL = {
     "x2d": "N6",
     "x2d": "N6",
     "h2d-pro": "O1E",
     "h2d-pro": "O1E",
     "a2l": "N9",
     "a2l": "N9",
+    "h3c": "N10",
 }
 }
 
 
 # Wiki firmware release history pages (primary version source)
 # 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
     # _fetch_all_versions_from_wiki silently returns [] on 404 so this is safe
     # to ship before Bambu publishes the page.
     # to ship before Bambu publishes the page.
     "a2l": "/en/a2l/manual/a2l-firmware-release-history",
     "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:
             if versions:
                 return 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)" —
             # Fallback: heading text with "XX.XX.XX.XX (YYYYMMDD)" —
             # accept both ASCII "()" and full-width "()" (U+FF08/U+FF09)
             # accept both ASCII "()" and full-width "()" (U+FF08/U+FF09)
             # which some pages (A1, A1-mini, P2S) use.
             # which some pages (A1, A1-mini, P2S) use.

+ 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.slot_nozzle import resolve_slot_nozzle
 from backend.app.services.spool_filament_preset import printer_safe_filament_id
 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.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__)
 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 {}
     raw = getattr(state, "raw_data", None) or {}
     ams_raw = raw.get("ams")
     ams_raw = raw.get("ams")
     units = ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
     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]] = []
     trays: list[tuple[int, int, int, dict]] = []
     for unit in units:
     for unit in units:
         if not isinstance(unit, dict):
         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))
                 tray_id = int(tray.get("id", 0))
             except (TypeError, ValueError):
             except (TypeError, ValueError):
                 continue
                 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")
     vt_raw = raw.get("vt_tray")
     if isinstance(vt_raw, dict):
     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))
             vt_id = int(vt.get("id", 254))
         except (TypeError, ValueError):
         except (TypeError, ValueError):
             continue
             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
     return trays
 
 
 
 

+ 3 - 2
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.notify_client import NotifyClient, NotifyError, notify_credentials, notify_supports_photos
 from backend.app.services.print_confirmation import one_tap_url
 from backend.app.services.print_confirmation import one_tap_url
 from backend.app.utils.notification_photos import save_notification_photo
 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__)
 logger = logging.getLogger(__name__)
 
 
@@ -1929,10 +1930,10 @@ class NotificationService:
                     tray_id = u.get("tray_id", 0)
                     tray_id = u.get("tray_id", 0)
                     material = u.get("material", "Unknown") or "Unknown"
                     material = u.get("material", "Unknown") or "Unknown"
                     used = u.get("weight_used", 0)
                     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"
                         slot_label = "Ext"
                     else:
                     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")
                     parts.append(f"{slot_label} {material}: {used:.1f}g")
                 variables["filament_details"] = " | ".join(parts)
                 variables["filament_details"] = " | ".join(parts)
             elif archive_data.get("filament_slots"):
             elif archive_data.get("filament_slots"):

+ 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.library import LibraryFile
 from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.services.inventory_mode import spoolman_owns_assignments
 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 import threemf_tools
 from backend.app.utils.safe_path import safe_join_under
 from backend.app.utils.safe_path import safe_join_under
+from backend.app.utils.tray_ids import TrayLayout
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -161,12 +163,8 @@ def _parse_mapping(mapping: list[int] | str | None) -> list[int] | None:
     return 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]:
 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.models.spoolman_slot_assignment import SpoolmanSlotAssignment
     from backend.app.services.spoolman_tracking import _get_spoolman_client_with_fallback, _spool_cost_per_gram
     from backend.app.services.spoolman_tracking import _get_spoolman_client_with_fallback, _spool_cost_per_gram
 
 
+    layout = printer_tray_layout(printer_id)
     rows = [
     rows = [
         row
         row
         for row in (
         for row in (
@@ -185,7 +184,7 @@ async def _spoolman_cost_by_tray(db: AsyncSession, printer_id: int, trays: set[i
         )
         )
         .scalars()
         .scalars()
         .all()
         .all()
-        if _global_tray_id(row) in trays
+        if _global_tray_id(row, layout) in trays
     ]
     ]
     if not rows:
     if not rows:
         return {}
         return {}
@@ -196,7 +195,7 @@ async def _spoolman_cost_by_tray(db: AsyncSession, printer_id: int, trays: set[i
             return {}
             return {}
         for row in rows:
         for row in rows:
             per_gram = _spool_cost_per_gram(await client.get_spool(row.spoolman_spool_id))
             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
     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)
         logger.debug("Spoolman prices unavailable for the queue estimate: %s", exc)
     return by_tray
     return by_tray
@@ -315,7 +314,8 @@ async def estimate_queue_source_cost(
             .scalars()
             .scalars()
             .all()
             .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
     total = 0.0
     for filament in usage:
     for filament in usage:

+ 29 - 18
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.print_cost_estimate import estimate_queue_source_cost
 from backend.app.services.printer_manager import (
 from backend.app.services.printer_manager import (
     printer_manager,
     printer_manager,
+    printer_tray_layout,
     supports_airduct,
     supports_airduct,
     supports_chamber_heater,
     supports_chamber_heater,
     supports_chamber_temp,
     supports_chamber_temp,
@@ -92,6 +93,12 @@ from backend.app.utils.threemf_tools import (
     plate_bed_temperature,
     plate_bed_temperature,
     select_plate_gcode_name,
     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__)
 logger = logging.getLogger(__name__)
 
 
@@ -783,15 +790,16 @@ def _consumed_mapping_entries(mapping: list | None, required: list[dict] | None)
     return entries or 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:
     if tray_id is None:
         return False
         return False
     try:
     try:
-        return int(tray_id) >= _EXTERNAL_TRAY_ID_MIN
+        value = int(tray_id)
     except (TypeError, ValueError):
     except (TypeError, ValueError):
         return False
         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:
 def _might_be_dual_nozzle(printer_model: str | None, status) -> bool:
@@ -820,7 +828,8 @@ def _might_be_dual_nozzle(printer_model: str | None, status) -> bool:
     if raw.get("ams_extruder_map"):
     if raw.get("ams_extruder_map"):
         return True
         return True
     vt_trays = raw.get("vt_tray") or []
     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:
 def _int_or(value, default: int) -> int:
@@ -871,15 +880,14 @@ async def _edited_since_loaded(db: AsyncSession, item: PrintQueueItem) -> bool:
     return True
     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
     Mirrors the calculation in ``_build_loaded_filaments``, which is what
     produces the ids stored in ``PrintQueueItem.ams_mapping`` — the two must
     produces the ids stored in ``PrintQueueItem.ams_mapping`` — the two must
     agree or a mapping cannot be read back against live tray telemetry.
     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:
 def _used_global_tray_ids(item: PrintQueueItem | None) -> set[int] | None:
@@ -4301,6 +4309,7 @@ class PrintScheduler:
             # place.
             # place.
             return None
             return None
         by_tray = {f["global_tray_id"]: f for f in loaded}
         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
         # Cheap pass first, on live status alone: every tray the mapping names
         # has to exist here. This catches a foreign mapping without opening the
         # has to exist here. This catches a foreign mapping without opening the
@@ -4311,7 +4320,7 @@ class PrintScheduler:
                 continue
                 continue
             if tray in by_tray:
             if tray in by_tray:
                 continue
                 continue
-            if tray >= 254:
+            if tray >= 254 or layout.is_external(tray):
                 # An external feed we have not heard about is absence of
                 # An external feed we have not heard about is absence of
                 # evidence about *that slot* — the rest of the mapping is still
                 # evidence about *that slot* — the rest of the mapping is still
                 # worth judging, so skip it rather than abandoning the pass.
                 # worth judging, so skip it rather than abandoning the pass.
@@ -4360,7 +4369,7 @@ class PrintScheduler:
                 # has to match too: with only another colour in the AMS, the
                 # has to match too: with only another colour in the AMS, the
                 # printer asking for the spool beats printing in that colour.
                 # printer asking for the spool beats printing in that colour.
                 want = canonical_filament_type(req.get("type"))
                 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(
                     ams_tray = next(
                         (
                         (
                             f
                             f
@@ -4741,10 +4750,11 @@ class PrintScheduler:
 
 
         # Parse AMS units from raw_data
         # Parse AMS units from raw_data
         ams_data = status.raw_data.get("ams", [])
         ams_data = status.raw_data.get("ams", [])
+        layout = TrayLayout.from_state(status)
         for ams_unit in ams_data:
         for ams_unit in ams_data:
             ams_id = int(ams_unit.get("id", 0))
             ams_id = int(ams_unit.get("id", 0))
             trays = ams_unit.get("tray", [])
             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:
             for tray in trays:
                 tray_type = tray.get("tray_type")
                 tray_type = tray.get("tray_type")
@@ -4757,7 +4767,7 @@ class PrintScheduler:
                     color = self._normalize_color(tray_color)
                     color = self._normalize_color(tray_color)
                     # Calculate global tray ID
                     # Calculate global tray ID
                     # AMS-HT units have IDs starting at 128 with a single tray
                     # 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(
                     filaments.append(
                         {
                         {
@@ -6687,7 +6697,7 @@ class PrintScheduler:
         Never raises: a notification provider being down must not stop the
         Never raises: a notification provider being down must not stop the
         suspension itself from taking effect.
         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:
         try:
             await notification_service.on_ams_drying_suspended(
             await notification_service.on_ams_drying_suspended(
                 printer.id,
                 printer.id,
@@ -7223,6 +7233,7 @@ class PrintScheduler:
         if not isinstance(ams_list, list):
         if not isinstance(ams_list, list):
             ams_list = []
             ams_list = []
         best = 0
         best = 0
+        layout = TrayLayout.from_state(state)
         for ams in ams_list:
         for ams in ams_list:
             if not isinstance(ams, dict):
             if not isinstance(ams, dict):
                 continue
                 continue
@@ -7235,10 +7246,10 @@ class PrintScheduler:
                 # contract, so step over it instead.
                 # contract, so step over it instead.
                 if not isinstance(tray, dict):
                 if not isinstance(tray, dict):
                     continue
                     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
                     continue
                 best = max(best, self._target_for_tray_type(tray.get("tray_type"), targets))
                 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 []:
             for vt in raw_data.get("vt_tray") or []:
                 if not isinstance(vt, dict):
                 if not isinstance(vt, dict):
                     continue
                     continue
@@ -9990,7 +10001,7 @@ class PrintScheduler:
             # from a hand-edited row) failing where it always failed, in the
             # from a hand-edited row) failing where it always failed, in the
             # command builder, rather than here.
             # command builder, rather than here.
             and isinstance(ams_mapping, list)
             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)
             and not _might_be_dual_nozzle(printer.model, pre_status)
         ):
         ):
             from backend.app.services.filament_requirements import extract_filament_requirements
             from backend.app.services.filament_requirements import extract_filament_requirements
@@ -9998,7 +10009,7 @@ class PrintScheduler:
             consumed = _consumed_mapping_entries(
             consumed = _consumed_mapping_entries(
                 ams_mapping, extract_filament_requirements(file_path, plate_id=effective_plate_id)
                 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
                 # Exact for this plate: the command builder must not turn the
                 # AMS back on for a real tray another plate of the file uses,
                 # 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.
                 # whether the job asked for the AMS or already had it off.

+ 77 - 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.services.pause_eta import next_pause
 from backend.app.utils.ams_humidity import ams_humidity_percent
 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.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__)
 logger = logging.getLogger(__name__)
 
 
@@ -39,6 +49,7 @@ CHAMBER_TEMP_SUPPORTED_MODELS = frozenset(
         "H2D",
         "H2D",
         "H2DPRO",
         "H2DPRO",
         "H2S",  # H2 series
         "H2S",  # H2 series
+        "H3C",  # H3 series
         # Internal codes (from MQTT/SSDP)
         # Internal codes (from MQTT/SSDP)
         "BL-P001",  # X1/X1C
         "BL-P001",  # X1/X1C
         "C13",  # X1E
         "C13",  # X1E
@@ -50,6 +61,7 @@ CHAMBER_TEMP_SUPPORTED_MODELS = frozenset(
         "O1E",  # H2D Pro
         "O1E",  # H2D Pro
         "O2D",  # H2D Pro (alternate code)
         "O2D",  # H2D Pro (alternate code)
         "N7",  # P2S
         "N7",  # P2S
+        "N10",  # H3C
     ]
     ]
 )
 )
 
 
@@ -114,6 +126,7 @@ CHAMBER_HEATER_MODELS = frozenset(
         "H2D",
         "H2D",
         "H2DPRO",
         "H2DPRO",
         "H2S",
         "H2S",
+        "H3C",
         "X1E",
         "X1E",
         "X2D",
         "X2D",
         # Internal codes (from MQTT/SSDP)
         # Internal codes (from MQTT/SSDP)
@@ -125,6 +138,7 @@ CHAMBER_HEATER_MODELS = frozenset(
         "O2D",  # H2D Pro alternate code
         "O2D",  # H2D Pro alternate code
         "O1S",  # H2S
         "O1S",  # H2S
         "N6",  # X2D
         "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
     The chamber temperature SENSOR is more widely deployed than the chamber
     HEATER — X1C/P2S report chamber temp but ignore M141. Only H2C, H2D,
     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.
     command at the firmware level, so we 400 at the route to surface that.
     """
     """
     if not model:
     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
 # 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
 # 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
 # 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 —
 # needed (and the firmware ignores it). P2S has an airduct but no heater —
@@ -160,6 +174,7 @@ CHAMBER_AIRDUCT_MODELS = frozenset(
         "H2D",
         "H2D",
         "H2DPRO",
         "H2DPRO",
         "H2S",
         "H2S",
+        "H3C",
         # Internal codes (from MQTT/SSDP)
         # Internal codes (from MQTT/SSDP)
         "N7",  # P2S
         "N7",  # P2S
         "N6",  # X2D
         "N6",  # X2D
@@ -169,6 +184,7 @@ CHAMBER_AIRDUCT_MODELS = frozenset(
         "O1E",  # H2D Pro
         "O1E",  # H2D Pro
         "O2D",  # H2D Pro alternate code
         "O2D",  # H2D Pro alternate code
         "O1S",  # H2S
         "O1S",  # H2S
+        "N10",  # H3C
     ]
     ]
 )
 )
 
 
@@ -176,7 +192,7 @@ CHAMBER_AIRDUCT_MODELS = frozenset(
 def supports_airduct(model: str | None) -> bool:
 def supports_airduct(model: str | None) -> bool:
     """Check if a printer model has a cooling / heating airduct mode toggle.
     """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
     + H2D Pro whitelist. Distinct from `supports_chamber_heater` — P2S has
     the airduct toggle but no active heater, and X1E has the heater but no
     the airduct toggle but no active heater, and X1E has the heater but no
     airduct. The preheat stage cares about the intersection (heater AND
     airduct. The preheat stage cares about the intersection (heater AND
@@ -1296,6 +1312,7 @@ def _trays_on_extruder(
     ams_layout: list[tuple[int, bool]],
     ams_layout: list[tuple[int, bool]],
     active_extruder: int | None,
     active_extruder: int | None,
     ams_extruder_map: dict | None,
     ams_extruder_map: dict | None,
+    tray_layout: TrayLayout,
 ) -> set[int] | None:
 ) -> set[int] | None:
     """The global trays a local ``raw_slot`` can be on the AMS units wired to
     """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
     ``active_extruder`` (an AMS-HT's one tray for slot 0), or None when the
@@ -1313,10 +1330,8 @@ def _trays_on_extruder(
             return None
             return None
         if nozzle != active_extruder:
         if nozzle != active_extruder:
             continue
             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
     return trays
 
 
 
 
@@ -1326,6 +1341,7 @@ def resolve_expected_tray(
     mapping_raw: object,
     mapping_raw: object,
     active_extruder: int | None = None,
     active_extruder: int | None = None,
     ams_extruder_map: dict | None = None,
     ams_extruder_map: dict | None = None,
+    tray_layout: TrayLayout | None = None,
 ) -> int | None:
 ) -> int | None:
     """Globalise a raw firmware ``tray_tar``/``tray_pre`` value for the runout UI (#2587).
     """Globalise a raw firmware ``tray_tar``/``tray_pre`` value for the runout UI (#2587).
 
 
@@ -1349,35 +1365,34 @@ def resolve_expected_tray(
         * no regular AMS -> ``None``
         * no regular AMS -> ``None``
     - ``4``-``15`` -> already a global regular-AMS ID, returned as-is
     - ``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
     Returns ``None`` for anything it can't place, so the caller surfaces a
     "check the printer" message instead of pointing at the wrong slot.
     "check the printer" message instead of pointing at the wrong slot.
     """
     """
     if raw_slot is None or raw_slot in (255, -1):
     if raw_slot is None or raw_slot in (255, -1):
         return None
         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:
     if raw_slot == 254:
         return 254
         return 254
-    if 128 <= raw_slot <= 135:
+    if is_ams_ht_id(raw_slot):
         return raw_slot
         return raw_slot
     if 0 <= raw_slot <= 3:
     if 0 <= raw_slot <= 3:
         regular = [ams_id for ams_id, is_ht in ams_layout if not is_ht]
         regular = [ams_id for ams_id, is_ht in ams_layout if not is_ht]
         if len(regular) == 1:
         if len(regular) == 1:
-            return regular[0] * 4 + raw_slot
+            return layout.global_tray_id(regular[0], raw_slot)
         if len(regular) > 1:
         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:
             if len(candidates) == 1:
                 return candidates.pop()
                 return candidates.pop()
             # The print paused on the active nozzle, so the slot is on an AMS
             # The print paused on the active nozzle, so the slot is on an AMS
             # that feeds it.
             # 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:
             if narrowed is None:
                 return None
                 return None
             if candidates:
             if candidates:
@@ -1405,6 +1420,27 @@ def _ams_temp(raw: object) -> float | None:
     return value if math.isfinite(value) else 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(
 def ams_units_to_dicts(
     ams_list: list,
     ams_list: list,
     resolve_slot_k: Callable,
     resolve_slot_k: Callable,
@@ -1483,7 +1519,7 @@ def ams_units_to_dicts(
         humidity_value = int(round(humidity_pct)) if humidity_pct is not None else None
         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.
         # 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.
         # Active-cycle filament + target temperature for the badge.
         # Bambu does not echo the cycle's chosen filament/temp on the
         # Bambu does not echo the cycle's chosen filament/temp on the
@@ -1539,8 +1575,10 @@ def ams_units_to_dicts(
                 # Active-cycle filament name + target temperature
                 # Active-cycle filament name + target temperature
                 "dry_target_temp": dry_target_temp,
                 "dry_target_temp": dry_target_temp,
                 "dry_filament": dry_filament,
                 "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 ""),
                 "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
     return ams_units
@@ -1570,10 +1608,12 @@ def vt_trays_to_dicts(raw: object, resolve_slot_k: Callable) -> list[dict]:
         vt_k_value = vt_data.get("k")
         vt_k_value = vt_data.get("k")
         vt_cali_idx = vt_data.get("cali_idx")
         vt_cali_idx = vt_data.get("cali_idx")
         if vt_k_value is None:
         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_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))
         tray_id = int(vt_data.get("id", 254))
         vt_tray.append(
         vt_tray.append(
@@ -1629,6 +1669,7 @@ def printer_state_to_dict(
     # Parse virtual tray (external spool) — now a list
     # Parse virtual tray (external spool) — now a list
     if "vt_tray" in raw_data:
     if "vt_tray" in raw_data:
         vt_tray = vt_trays_to_dicts(raw_data["vt_tray"], resolve_slot_k)
         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)
     # Get ams_extruder_map from raw_data (populated by MQTT handler from AMS info field)
     ams_extruder_map = raw_data.get("ams_extruder_map", {})
     ams_extruder_map = raw_data.get("ams_extruder_map", {})
@@ -1688,6 +1729,7 @@ def printer_state_to_dict(
                 raw_data.get("mapping"),
                 raw_data.get("mapping"),
                 active_extruder=state.active_extruder,
                 active_extruder=state.active_extruder,
                 ams_extruder_map=nozzle_wiring(state),
                 ams_extruder_map=nozzle_wiring(state),
+                tray_layout=tray_layout,
             )
             )
             if state.state == "PAUSE"
             if state.state == "PAUSE"
             else None
             else None
@@ -1699,6 +1741,7 @@ def printer_state_to_dict(
                 raw_data.get("mapping"),
                 raw_data.get("mapping"),
                 active_extruder=state.active_extruder,
                 active_extruder=state.active_extruder,
                 ams_extruder_map=nozzle_wiring(state),
                 ams_extruder_map=nozzle_wiring(state),
+                tray_layout=tray_layout,
             )
             )
             if state.state == "PAUSE"
             if state.state == "PAUSE"
             else None
             else None
@@ -1808,6 +1851,7 @@ def printer_state_to_dict(
                 # The push replaces the whole rack: without it a slot lost its
                 # The push replaces the whole rack: without it a slot lost its
                 # material label on every push (PSF-3).
                 # material label on every push (PSF-3).
                 "filament_type": n.get("filament_type", ""),
                 "filament_type": n.get("filament_type", ""),
+                "addr_id": n.get("addr_id"),
             }
             }
             for n in (state.nozzle_rack or [])
             for n in (state.nozzle_rack or [])
         ],
         ],
@@ -1883,3 +1927,11 @@ async def init_printer_connections(db: AsyncSession):
                 printer.name,
                 printer.name,
                 outcome,
                 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.fts_routing import slot_extruder
 from backend.app.utils.printer_models import is_dual_nozzle_model
 from backend.app.utils.printer_models import is_dual_nozzle_model
+from backend.app.utils.tray_ids import TrayLayout
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -129,8 +130,9 @@ def reports_two_nozzles(state) -> bool:
     return bool(
     return bool(
         (len(nozzles) > 1 and getattr(nozzles[1], "nozzle_diameter", ""))
         (len(nozzles) > 1 and getattr(nozzles[1], "nozzle_diameter", ""))
         or raw_data.get("ams_extruder_map")
         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)
     )
     )
 
 
 
 

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

@@ -8,20 +8,30 @@ from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 from backend.app.services.bambu_mqtt import PrinterState
 from backend.app.services.bambu_mqtt import PrinterState
 from backend.app.services.inventory_mode import spoolman_owns_assignments
 from backend.app.services.inventory_mode import spoolman_owns_assignments
 from backend.app.services.notification_service import notification_service
 from backend.app.services.notification_service import notification_service
-from backend.app.services.printer_manager import printer_manager
+from backend.app.services.printer_manager import printer_manager, printer_tray_layout
+from backend.app.utils.tray_ids import (
+    EXTERNAL_DEPUTY_ID,
+    AmsType,
+    TrayLayout,
+    decode_wire_slot,
+    external_hotend,
+)
 
 
 
 
-def _global_tray_from_assignment(ams_id: int, tray_id: int) -> int:
+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."""
     """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."""
     """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):
+        # One external input per hotend, numbered like Bambu Studio's "Ext-n".
+        return f"Ext-{external_hotend(global_tray_id) + 1}"
     if global_tray_id == 254:
     if global_tray_id == 254:
         return "Ext-L"
         return "Ext-L"
     if global_tray_id == 255:
     if global_tray_id == 255:
@@ -31,8 +41,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.
     # 24-27 = A2L AMS-Lite (normalised unit 6); see a2l-am-unit-16.
     if 24 <= global_tray_id <= 27:
     if 24 <= global_tray_id <= 27:
         return f"Lite-{(global_tray_id % 4) + 1}"
         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}"
     return f"{chr(65 + ams_id)}{tray_id + 1}"
 
 
 
 
@@ -43,6 +57,7 @@ def _tray_profile_and_color_for_global_id(state: PrinterState | None, global_tra
 
 
     ams_raw = state.raw_data.get("ams", {})
     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 []
     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", [])
     vt_trays = state.raw_data.get("vt_tray", [])
     if not isinstance(vt_trays, list):
     if not isinstance(vt_trays, list):
@@ -67,8 +82,7 @@ def _tray_profile_and_color_for_global_id(state: PrinterState | None, global_tra
             if not isinstance(tray, dict):
             if not isinstance(tray, dict):
                 continue
                 continue
             tray_id = int(tray.get("id", -1))
             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"
                 profile = tray.get("tray_sub_brands") or tray.get("tray_type") or "Unknown"
                 color = tray.get("tray_color") or "Unknown"
                 color = tray.get("tray_color") or "Unknown"
                 return (profile, color)
                 return (profile, color)
@@ -76,37 +90,13 @@ def _tray_profile_and_color_for_global_id(state: PrinterState | None, global_tra
     return ("Unknown", "Unknown")
     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."""
     """Decode printer MQTT mapping values into Bambuddy global tray IDs."""
     if not isinstance(mapping_raw, list) or not mapping_raw:
     if not isinstance(mapping_raw, list) or not mapping_raw:
         return []
         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(
 async def notify_missing_spool_assignments_on_print_start(
@@ -120,7 +110,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 []
         [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
     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
     mapping_values = explicit_values if explicit_values else decoded_values
 
 
     used_global_trays = {value for value in mapping_values if value >= 0}
     used_global_trays = {value for value in mapping_values if value >= 0}
@@ -146,7 +137,7 @@ async def notify_missing_spool_assignments_on_print_start(
             # precisely to catch that tray.
             # precisely to catch that tray.
             table = SpoolmanSlotAssignment if await spoolman_owns_assignments(db) else SpoolAssignment
             table = SpoolmanSlotAssignment if await spoolman_owns_assignments(db) else SpoolAssignment
             rows = (await db.execute(table.__table__.select().where(table.printer_id == printer_id))).fetchall()
             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)
             missing_global = sorted(used_global_trays - assigned_global_trays)
             if not missing_global:
             if not missing_global:
@@ -165,12 +156,13 @@ async def _send_missing_assignment_notification(
 ) -> None:
 ) -> None:
     """Describe the unassigned trays and push them to the UI and the providers."""
     """Describe the unassigned trays and push them to the UI and the providers."""
     state = printer_manager.get_status(printer_id)
     state = printer_manager.get_status(printer_id)
+    layout = TrayLayout.from_state(state)
     missing_slots = []
     missing_slots = []
     for global_id in missing_global:
     for global_id in missing_global:
         profile, color = _tray_profile_and_color_for_global_id(state, global_id)
         profile, color = _tray_profile_and_color_for_global_id(state, global_id)
         missing_slots.append(
         missing_slots.append(
             {
             {
-                "slot": _slot_label_from_global_tray(global_id),
+                "slot": _slot_label_from_global_tray(global_id, layout),
                 "profile": profile,
                 "profile": profile,
                 "color": color,
                 "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.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.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.tag_normalization import normalize_tag_uid, normalize_tray_uuid
+from backend.app.utils.tray_ids import external_tray_id
 
 
 logger = logging.getLogger(__name__)
 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:
     if ams_id == 255:
         vt_trays = raw.get("vt_tray") or []
         vt_trays = raw.get("vt_tray") or []
         for tray in [vt_trays] if isinstance(vt_trays, dict) else vt_trays:
         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 tray
         return None
         return None
     units = raw.get("ams") or []
     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.config import settings as app_settings
 from backend.app.core.database import async_session
 from backend.app.core.database import async_session
 from backend.app.services import spool_swaps
 from backend.app.services import spool_swaps
+from backend.app.services.printer_manager import printer_tray_layout
 from backend.app.services.spoolman import (
 from backend.app.services.spoolman import (
     SpoolmanClientError,
     SpoolmanClientError,
     SpoolmanNotFoundError,
     SpoolmanNotFoundError,
@@ -25,6 +26,7 @@ from backend.app.services.spoolman import (
     get_spoolman_client,
     get_spoolman_client,
     init_spoolman_client,
     init_spoolman_client,
 )
 )
+from backend.app.utils.tray_ids import EXTERNAL_AMS_ID, TrayLayout, external_slot_index
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -74,23 +76,16 @@ def _hash_serial_to_hex32(serial: str) -> str:
     return format(hash_value, "X").zfill(8)
     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."""
     """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."""
     """Mirror frontend getFallbackSpoolTag(serial, amsId, trayId) exactly."""
     if not printer_serial:
     if not printer_serial:
         return ""
         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)
     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)
     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).
     """Get the best spool identifier from tray info (prefer tray_uuid over tag_uid).
 
 
     Returns empty string if no usable identifier is found.
     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):
     if tag_uid and tag_uid != _ZERO_TAG_UID and _is_non_zero_identifier(tag_uid):
         return tag_uid
         return tag_uid
     if global_tray_id is not None:
     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 ""
     return ""
 
 
 
 
@@ -326,7 +323,7 @@ def _resolve_slot_to_tray_fallback(
     # fields ``bambu_mqtt`` maintains on the state object itself, so an empty
     # fields ``bambu_mqtt`` maintains on the state object itself, so an empty
     # payload must not short-circuit past it.
     # payload must not short-circuit past it.
     if raw_data and not tray_state_only:
     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:
         if decoded:
             return decoded, "mqtt"
             return decoded, "mqtt"
 
 
@@ -356,12 +353,14 @@ def build_ams_tray_lookup(raw_data: dict) -> dict[int, dict]:
     """
     """
     lookup = {}
     lookup = {}
     ams_data = raw_data.get("ams", [])
     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:
     for ams_unit in ams_data:
         ams_id = int(ams_unit.get("id", 0))
         ams_id = int(ams_unit.get("id", 0))
         for tray in ams_unit.get("tray", []):
         for tray in ams_unit.get("tray", []):
             tray_id = int(tray.get("id", 0))
             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] = {
             lookup[global_tray_id] = {
                 "tray_uuid": tray.get("tray_uuid", ""),
                 "tray_uuid": tray.get("tray_uuid", ""),
                 "tag_uid": tray.get("tag_uid", ""),
                 "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):
         if not isinstance(vt, dict):
             continue
             continue
         vt_id = int(vt.get("id", 254))
         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)
         remain = vt.get("remain", -1)
         if isinstance(remain, int) and 0 <= remain <= 100:
         if isinstance(remain, int) and 0 <= remain <= 100:
-            snapshot[f"255-{vt_tray_id}"] = {
+            snapshot[f"{EXTERNAL_AMS_ID}-{vt_tray_id}"] = {
                 "remain": remain,
                 "remain": remain,
                 "tray_uuid": vt.get("tray_uuid", "") or "",
                 "tray_uuid": vt.get("tray_uuid", "") or "",
             }
             }
@@ -1085,6 +1085,7 @@ async def _report_spool_usage_for_slots(
     Returns number of spools successfully updated.
     Returns number of spools successfully updated.
     """
     """
     spools_updated = 0
     spools_updated = 0
+    layout = printer_tray_layout(printer_id)
     for slot_id, grams_used in filament_usage_items:
     for slot_id, grams_used in filament_usage_items:
         if grams_used <= 0:
         if grams_used <= 0:
             continue
             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)
             logger.debug("[SPOOLMAN] Slot %s: no tray at global_tray_id %s", slot_id, global_tray_id)
             continue
             continue
 
 
-        is_external = global_tray_id >= 254
+        is_external = layout.is_external(global_tray_id)
         tray_type = tray_info.get("tray_type", "")
         tray_type = tray_info.get("tray_type", "")
         logger.debug(
         logger.debug(
             "[SPOOLMAN] Slot %s resolved to global_tray_id %s (tray_type=%s, external=%s)",
             "[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.
         # same fetch the colour and material already pay for.
         spool_obj: dict | None = None
         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:
         if spool_tag:
             spool = await client.find_spool_by_tag(spool_tag)
             spool = await client.find_spool_by_tag(spool_tag)
             if spool:
             if spool:
@@ -1128,7 +1129,7 @@ async def _report_spool_usage_for_slots(
                 spool_material = (spool.get("filament") or {}).get("material")
                 spool_material = (spool.get("filament") or {}).get("material")
 
 
         if spool_id_to_use is None and printer_id is not None:
         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)
             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:
             if spool_id_to_use is not None:
                 resolution_path = "slot-assignment"
                 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.
         # grams with the one it replaced (#3325); the first is the archive's.
         shares = None
         shares = None
         if swaps is not 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:
             if shares and shares[0][0] != spool_id_to_use:
                 spool_id_to_use = shares[0][0]
                 spool_id_to_use = shares[0][0]
                 resolution_path = "swap log"
                 resolution_path = "swap log"
@@ -1238,6 +1239,7 @@ async def _report_spool_usage_split_by_tray_changes(
 
 
     spools_updated = 0
     spools_updated = 0
     handled_global_tray_ids: set[int] = set()
     handled_global_tray_ids: set[int] = set()
+    layout = printer_tray_layout(printer_id)
 
 
     for usage in filament_usage:
     for usage in filament_usage:
         slot_id = usage.get("slot_id", 0)
         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_material: str | None = None
             spool_obj: dict | 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:
             if spool_tag:
                 spool = await client.find_spool_by_tag(spool_tag)
                 spool = await client.find_spool_by_tag(spool_tag)
                 if spool:
                 if spool:
@@ -1286,7 +1288,7 @@ async def _report_spool_usage_split_by_tray_changes(
                     spool_material = (spool.get("filament") or {}).get("material")
                     spool_material = (spool.get("filament") or {}).get("material")
 
 
             if spool_id_to_use is None:
             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)
                 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:
                 if spool_id_to_use is not None:
                     resolution_path = "slot-assignment"
                     resolution_path = "slot-assignment"
@@ -1296,7 +1298,7 @@ async def _report_spool_usage_split_by_tray_changes(
             if swaps is not None:
             if swaps is not None:
                 is_last = seg_idx + 1 >= len(tray_changes)
                 is_last = seg_idx + 1 >= len(tray_changes)
                 seg_shares = swaps.shares(
                 seg_shares = swaps.shares(
-                    *_global_tray_id_to_ams_slot(tray_global),
+                    *_global_tray_id_to_ams_slot(tray_global, layout),
                     slot_id,
                     slot_id,
                     segment_grams,
                     segment_grams,
                     tray_changes[seg_idx][1],
                     tray_changes[seg_idx][1],
@@ -1586,7 +1588,9 @@ async def _report_partial_usage(
                 diameter = 1.75
                 diameter = 1.75
 
 
                 if tray_info:
                 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:
                     if spool_tag:
                         spool = await client.find_spool_by_tag(spool_tag)
                         spool = await client.find_spool_by_tag(spool_tag)
                         if spool:
                         if spool:
@@ -1960,16 +1964,17 @@ def _print_used_tray_keys(
     read into locals beforehand.
     read into locals beforehand.
     """
     """
     keys: set[tuple[int, int]] = set()
     keys: set[tuple[int, int]] = set()
+    layout = TrayLayout.from_state(state)
     for global_tray_id in list(slot_to_tray or []):
     for global_tray_id in list(slot_to_tray or []):
         if isinstance(global_tray_id, int) and global_tray_id >= 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))
     for change in getattr(state, "tray_change_log", None) or []:
     for change in getattr(state, "tray_change_log", None) or []:
         if isinstance(change, (tuple, list)) and change:
         if isinstance(change, (tuple, list)) and change:
             global_tray_id = change[0]
             global_tray_id = change[0]
             if isinstance(global_tray_id, int) and global_tray_id >= 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:
     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
     return keys
 
 
 
 
@@ -1996,6 +2001,7 @@ async def _report_remain_delta_for_slots(
     """
     """
     spools_updated = 0
     spools_updated = 0
     not_in_print: list[str] = []
     not_in_print: list[str] = []
+    layout = printer_tray_layout(printer_id)
     for slot_key, start in tray_remain_start.items():
     for slot_key, start in tray_remain_start.items():
         try:
         try:
             ams_id_str, tray_id_str = slot_key.split("-", 1)
             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
         # Skip slots already handled by the 3MF path. Encoding mirrors
         # build_ams_tray_lookup: VT trays land at 254/255, AMS-HT keeps
         # 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.
         # 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:
         if global_tray_id in handled_global_tray_ids:
             continue
             continue
 
 

+ 35 - 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_assignment import SpoolAssignment
 from backend.app.models.spool_usage_history import SpoolUsageHistory
 from backend.app.models.spool_usage_history import SpoolUsageHistory
 from backend.app.services import spool_swaps
 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__)
 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
     ``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),
     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
     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):
         if not isinstance(vt, dict):
             continue
             continue
         try:
         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.
     """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
     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 = []
     result = []
     for value in mapping_raw:
     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
     # Only return if at least one valid mapping exists
     if all(v < 0 for v in result):
     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
     # Build map of normalized color → list of global tray IDs
     color_to_trays: dict[str, list[int]] = {}
     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:
     for ams_unit in ams_data:
         ams_id = int(ams_unit.get("id", 0))
         ams_id = int(ams_unit.get("id", 0))
         for tray in ams_unit.get("tray", []):
         for tray in ams_unit.get("tray", []):
@@ -237,11 +227,7 @@ def _match_slots_by_color(
                 continue
                 continue
             # Normalize AMS color: strip alpha (last 2 chars), lowercase
             # Normalize AMS color: strip alpha (last 2 chars), lowercase
             norm = tray_color[:6].lower() if len(tray_color) >= 6 else tray_color.lower()
             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:
     if not color_to_trays:
         return None
         return None
@@ -668,11 +654,11 @@ async def on_print_start(
         if not isinstance(vt, dict):
         if not isinstance(vt, dict):
             continue
             continue
         vt_id = int(vt.get("id", 254))
         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)
         remain = vt.get("remain", -1)
         if isinstance(remain, int) and 0 <= remain <= 100:
         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:
         else:
             skipped_invalid.append(f"VT{vt_id}(remain={remain})")
             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
             # Without this guard, swapping a spool in an UNUSED slot mid-print
             # makes that slot's remain% drop to 0, which the fallback below
             # makes that slot's remain% drop to 0, which the fallback below
             # would otherwise charge to the originally-assigned spool.
             # 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()
             print_used_keys: set[tuple[int, int]] = set()
             if ams_mapping:
             if ams_mapping:
@@ -970,9 +951,9 @@ async def on_print_complete(
                 if not isinstance(vt, dict):
                 if not isinstance(vt, dict):
                     continue
                     continue
                 vt_id = int(vt.get("id", 254))
                 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)
                 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:
             for assign_ams_id, assign_tray_id, current_remain, tray_label in trays_to_check:
                 key = (assign_ams_id, assign_tray_id)
                 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.core.config import settings as app_settings
     from backend.app.models.archive import PrintArchive
     from backend.app.models.archive import PrintArchive
     from backend.app.models.print_queue import PrintQueueItem
     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
     from backend.app.utils.threemf_tools import extract_filament_usage_from_3mf
 
 
     file_path: Path | None = threemf_path
     file_path: Path | None = threemf_path
     archive: PrintArchive | None = None
     archive: PrintArchive | None = None
+    tray_layout = printer_tray_layout(printer_id, printer_manager)
 
 
     if file_path is None and archive_id:
     if file_path is None and archive_id:
         result = await db.execute(select(PrintArchive).where(PrintArchive.id == 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
         raw_data = getattr(state, "raw_data", None) if state else None
         if raw_data:
         if raw_data:
             mqtt_mapping = raw_data.get("mapping")
             mqtt_mapping = raw_data.get("mapping")
-            decoded = _decode_mqtt_mapping(mqtt_mapping)
+            decoded = _decode_mqtt_mapping(mqtt_mapping, TrayLayout.from_state(state))
             if decoded:
             if decoded:
                 slot_to_tray = decoded
                 slot_to_tray = decoded
                 mapping_source = "mqtt"
                 mapping_source = "mqtt"
@@ -1751,8 +1734,19 @@ async def _track_from_3mf(
     if status == "completed":
     if status == "completed":
         scale = 1.0
         scale = 1.0
     else:
     else:
+        from backend.app.utils.printer_models import usage_progress
+
         state = printer_manager.get_status(printer_id)
         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
         # Firmware resets progress to 0 on cancel — use last valid progress captured during print
         if progress <= 0 and last_progress > 0:
         if progress <= 0 and last_progress > 0:
             progress = last_progress
             progress = last_progress
@@ -1896,15 +1890,7 @@ async def _track_from_3mf(
                     continue
                     continue
 
 
                 # Convert global tray ID to (ams_id, tray_id)
                 # 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)
                 seg_key = (seg_ams_id, seg_tray_id)
                 if seg_key in covered_before:
                 if seg_key in covered_before:
@@ -2057,16 +2043,7 @@ async def _track_from_3mf(
             if global_tray_id is None:
             if global_tray_id is None:
                 global_tray_id = slot_id - 1
                 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(
         logger.info(
             "[UsageTracker] 3MF: slot_id=%d -> global_tray=%d -> AMS%d-T%d (used_g=%.1f, tray_now_override=%s)",
             "[UsageTracker] 3MF: slot_id=%d -> global_tray=%d -> AMS%d-T%d (used_g=%.1f, tray_now_override=%s)",

+ 55 - 0
backend/app/utils/printer_models.py

@@ -24,6 +24,7 @@ PRINTER_MODEL_MAP = {
     "Bambu Lab H2S": "H2S",
     "Bambu Lab H2S": "H2S",
     "Bambu Lab X2D": "X2D",
     "Bambu Lab X2D": "X2D",
     "Bambu Lab A2L": "A2L",
     "Bambu Lab A2L": "A2L",
+    "Bambu Lab H3C": "H3C",
 }
 }
 
 
 # Map from printer_model_id (internal codes in slice_info.config) to short names
 # Map from printer_model_id (internal codes in slice_info.config) to short names
@@ -61,6 +62,10 @@ PRINTER_MODEL_ID_MAP = {
     "O1C": "H2C",
     "O1C": "H2C",
     "O1C2": "H2C",
     "O1C2": "H2C",
     "O1S": "H2S",
     "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",
         "H2DPRO",
         "H2C",
         "H2C",
         "H2S",
         "H2S",
+        "H3C",
         # Internal codes
         # Internal codes
         "N1",  # A1 Mini
         "N1",  # A1 Mini
         "N2S",  # A1
         "N2S",  # A1
@@ -120,6 +126,7 @@ LINEAR_RAIL_MODELS = frozenset(
         "O1C",  # H2C
         "O1C",  # H2C
         "O1C2",  # H2C (dual nozzle variant)
         "O1C2",  # H2C (dual nozzle variant)
         "O1S",  # H2S
         "O1S",  # H2S
+        "N10",  # H3C
     ]
     ]
 )
 )
 
 
@@ -374,6 +381,54 @@ def is_dual_nozzle_model(model: str | None) -> bool:
     return normalized in DUAL_NOZZLE_MODELS
     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:
 def is_nozzle_rack_model(model: str | None) -> bool:
     """Return True if the model mounts its nozzles from a swappable rack (H2C).
     """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

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

@@ -1585,6 +1585,64 @@ class TestAMSLoadUnloadAPI:
         client.ams_load_filament.assert_called_once_with(tray_id, extruder_id=None)
         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'}"
         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.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     @pytest.mark.parametrize("tray_id", [127, 136, 512])
     @pytest.mark.parametrize("tray_id", [127, 136, 512])

+ 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
     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):
 async def test_an_archived_spool_is_refused(async_client: AsyncClient, printer_id):
     response = await _assign(async_client, printer_id, spool={**SPOOL, "archived": True})
     response = await _assign(async_client, printer_id, spool={**SPOOL, "archived": True})
 
 

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

@@ -0,0 +1,771 @@
+"""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_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

+ 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)

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

@@ -0,0 +1,99 @@
+"""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]

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

@@ -3,6 +3,12 @@
 import pytest
 import pytest
 
 
 from backend.app.services.camera import get_camera_port, supports_rtsp
 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 (
 from backend.app.utils.printer_models import (
     CARBON_ROD_MODELS,
     CARBON_ROD_MODELS,
     LINEAR_RAIL_MODELS,
     LINEAR_RAIL_MODELS,
@@ -12,6 +18,7 @@ from backend.app.utils.printer_models import (
     has_external_storage,
     has_external_storage,
     has_remote_storage_toggle,
     has_remote_storage_toggle,
     is_dual_nozzle_model,
     is_dual_nozzle_model,
+    is_nozzle_rack_model,
     normalize_printer_model,
     normalize_printer_model,
     normalize_printer_model_id,
     normalize_printer_model_id,
     supports_nozzle_flow_type,
     supports_nozzle_flow_type,
@@ -173,6 +180,56 @@ class TestA2LModel:
         assert "N9" not in STEEL_ROD_MODELS
         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:
 class TestA1SeriesModelIds:
     """Regression guard for the A1-family internal-code → display-name map.
     """Regression guard for the A1-family internal-code → display-name map.
 
 

+ 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)

+ 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

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


+ 46 - 0
frontend/src/__tests__/components/AssignToAmsModal.test.tsx

@@ -491,4 +491,50 @@ describe('AssignToAmsModal', () => {
       });
       });
     });
     });
   });
   });
+
+  describe('on the H3C (AMS 3H unit 32, nine per-hotend external inputs)', () => {
+    const H3C_STATUS = {
+      ...PRINTER_STATUS_ONLINE,
+      ams: [{
+        ...PRINTER_STATUS_ONLINE.ams[0],
+        id: 32,
+        ams_type: 6,
+        module_type: 'n10a',
+        tray: Array.from({ length: 9 }, (_, k) => ({ id: k, ...BLANK_TRAY })),
+      }],
+      vt_tray: Array.from({ length: 9 }, (_, k) => ({ id: 247 + k, ...BLANK_TRAY })),
+      ams_extruder_map: {},
+    };
+
+    beforeEach(() => {
+      vi.mocked(api.getPrinterStatus).mockResolvedValue(H3C_STATUS as never);
+    });
+
+    it('assigns to an AMS 3H slot by its unit 32 and slot', async () => {
+      const user = userEvent.setup();
+      render(<AssignToAmsModal isOpen={true} onClose={vi.fn()} spool={SPOOL as never} printerId={1} spoolmanMode={false} />);
+
+      await waitFor(() => expect(screen.queryAllByTestId('ams-slot')).toHaveLength(9));
+      await user.click(screen.getByRole('button', { name: 'AMS 3H Slot 9' }));
+
+      await waitFor(() => {
+        expect(api.assignSpool).toHaveBeenCalledWith(expect.objectContaining({ printer_id: 1, ams_id: 32, tray_id: 8 }));
+      });
+    });
+
+    it('names the external inputs Ext-1..Ext-9 and stores them under ams_id 255', async () => {
+      const user = userEvent.setup();
+      render(<AssignToAmsModal isOpen={true} onClose={vi.fn()} spool={SPOOL as never} printerId={1} spoolmanMode={false} />);
+
+      // Input 250 feeds hotend 5: "Ext-6", stored as (255, 5).
+      const ext6 = await screen.findByTitle('Ext-6');
+      for (let n = 1; n <= 9; n++) expect(screen.getByTitle(`Ext-${n}`)).toBeInTheDocument();
+      expect(screen.queryByText('Ext-L')).not.toBeInTheDocument();
+      await user.click(ext6);
+
+      await waitFor(() => {
+        expect(api.assignSpool).toHaveBeenCalledWith(expect.objectContaining({ printer_id: 1, ams_id: 255, tray_id: 5 }));
+      });
+    });
+  });
 });
 });

+ 22 - 0
frontend/src/__tests__/components/QrAssignTargetModal.test.tsx

@@ -104,6 +104,28 @@ describe('QrAssignTargetModal', () => {
     expect(screen.getByText(/Target: X1C · A1/)).toBeInTheDocument();
     expect(screen.getByText(/Target: X1C · A1/)).toBeInTheDocument();
   });
   });
 
 
+  it('lists an H3C as its AMS 3H slots and its per-hotend inputs Ext-1..Ext-9', async () => {
+    server.use(
+      http.get('/api/v1/printers/1/status', () =>
+        HttpResponse.json({
+          ams: [{ id: 32, ams_type: 6, is_ams_ht: false, tray: Array.from({ length: 9 }, (_, k) => ({ id: k })) }],
+          vt_tray: Array.from({ length: 9 }, (_, k) => ({ id: 247 + k })),
+        }),
+      ),
+    );
+    render(<QrAssignTargetModal isOpen onClose={noop} spoolmanMode={false} />);
+
+    expect(await screen.findByRole('button', { name: /^3H-9\b/ })).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: /^3H-1\b/ })).toBeInTheDocument();
+    for (let n = 1; n <= 9; n++) expect(screen.getByRole('button', { name: new RegExp(`^Ext-${n}\\b`) })).toBeInTheDocument();
+    expect(screen.queryByRole('button', { name: /^Ext-L\b/ })).not.toBeInTheDocument();
+
+    // Input 250 feeds hotend 5: Ext-6.
+    fireEvent.click(screen.getByRole('button', { name: /^Ext-6\b/ }));
+    fireEvent.click(screen.getByRole('button', { name: /set target & scan/i }));
+    expect(await screen.findByText(/Target: X1C · Ext-6/)).toBeInTheDocument();
+  });
+
   it('storage tab: scan button enables only after a structured location is picked', async () => {
   it('storage tab: scan button enables only after a structured location is picked', async () => {
     render(<QrAssignTargetModal isOpen onClose={noop} spoolmanMode={false} />);
     render(<QrAssignTargetModal isOpen onClose={noop} spoolmanMode={false} />);
 
 

+ 30 - 1
frontend/src/__tests__/components/spoolbuddy/AmsUnitCard.test.tsx

@@ -6,7 +6,7 @@
  */
  */
 
 
 import { describe, it, expect, vi } from 'vitest';
 import { describe, it, expect, vi } from 'vitest';
-import { render, screen } from '@testing-library/react';
+import { fireEvent, render, screen } from '@testing-library/react';
 import React from 'react';
 import React from 'react';
 import { AmsUnitCard } from '../../../components/spoolbuddy/AmsUnitCard';
 import { AmsUnitCard } from '../../../components/spoolbuddy/AmsUnitCard';
 import type { AMSUnit, AMSTray } from '../../../api/client';
 import type { AMSUnit, AMSTray } from '../../../api/client';
@@ -170,4 +170,33 @@ describe('AmsUnitCard', () => {
     const activeSlot = container.querySelector('.ring-2.ring-bambu-green');
     const activeSlot = container.querySelector('.ring-2.ring-bambu-green');
     expect(activeSlot).not.toBeNull();
     expect(activeSlot).not.toBeNull();
   });
   });
+
+  describe('the H3C AMS 3H (unit 32, nine slots)', () => {
+    const ams3h = () => makeUnit({
+      id: 32,
+      ams_type: 6,
+      module_type: 'n10a',
+      tray: Array.from({ length: 9 }, (_, k) => makeTray({ id: k, tray_type: k === 7 ? '' : 'PLA', remain: -1 })),
+    });
+
+    it('draws one card named AMS 3H with nine slots', () => {
+      const { container } = render(<AmsUnitCard unit={ams3h()} activeSlot={null} />);
+      expect(screen.getByText('AMS 3H')).toBeDefined();
+      for (let n = 1; n <= 9; n++) expect(screen.getByText(String(n))).toBeDefined();
+      expect(container.querySelector('.grid-cols-5')).not.toBeNull();
+      expect(container.querySelector('.grid-cols-4')).toBeNull();
+    });
+
+    it('hands the slot (tray id 0-8) of the AMS 3H to the configure callback', () => {
+      const onConfigureSlot = vi.fn();
+      render(<AmsUnitCard unit={ams3h()} activeSlot={null} onConfigureSlot={onConfigureSlot} />);
+      fireEvent.click(screen.getByRole('button', { name: 'AMS 3H Slot 9' }));
+      expect(onConfigureSlot).toHaveBeenCalledWith(32, 8, expect.objectContaining({ id: 8 }));
+    });
+
+    it('rings the active slot past the fourth', () => {
+      const { container } = render(<AmsUnitCard unit={ams3h()} activeSlot={6} />);
+      expect(container.querySelectorAll('.ring-2.ring-bambu-green')).toHaveLength(1);
+    });
+  });
 });
 });

+ 43 - 0
frontend/src/__tests__/hooks/useFilamentMapping.test.ts

@@ -1651,3 +1651,46 @@ describe('buildLoadedFilaments — a slot set on the printer screen (#3279)', ()
     expect(names).toEqual(['Translucent Orange', 'Translucent Orange']);
     expect(names).toEqual(['Translucent Orange', 'Translucent Orange']);
   });
   });
 });
 });
+
+describe('buildLoadedFilaments - H3C rack printer', () => {
+  // AMS 3H = unit 32 (type 6), slots at global trays 28-36; one external input
+  // per hotend, 247-255, and a single extruder.
+  const h3cStatus = (): PrinterStatus => {
+    const status = createPrinterStatus(
+      [{
+        id: 32,
+        ams_type: 6,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: 'DE4343FF', tray_info_idx: 'GFL99' },
+          { id: 6, tray_type: 'PETG', tray_color: 'F7D959FF', tray_info_idx: 'GFG99' },
+        ],
+      }] as unknown as PrinterStatus['ams'],
+      [247, 248, 249, 250, 251, 252, 253, 254, 255].map((id) => (
+        id === 250 ? { id, tray_type: 'TPU', tray_color: '000000FF' } : { id, tray_type: '' }
+      )) as PrinterStatus['vt_tray'],
+    );
+    (status as any).nozzles = [
+      { nozzle_type: 'hardened_steel', nozzle_diameter: '0.4' },
+      { nozzle_type: '', nozzle_diameter: '' },
+    ];
+    (status as any).ams_extruder_map = {};
+    return status;
+  };
+
+  it('numbers the AMS 3H slots 28 + slot and labels them 3H-n', () => {
+    const loaded = buildLoadedFilaments(h3cStatus());
+    const ams = loaded.filter((f) => !f.isExternal);
+    expect(ams.map((f) => [f.globalTrayId, f.label])).toEqual([[28, '3H-1'], [34, '3H-7']]);
+  });
+
+  it('does not read the nine external inputs as a dual-nozzle printer', () => {
+    const external = buildLoadedFilaments(h3cStatus()).find((f) => f.isExternal);
+    expect(external).toMatchObject({ globalTrayId: 250, trayId: 5, label: 'Ext-6' });
+    expect(external?.extruderId).toBeUndefined();
+  });
+
+  it('maps a filament to its AMS 3H slot', () => {
+    const reqs = { filaments: [{ slot_id: 1, type: 'PETG', color: '#F7D959', used_grams: 5 }] };
+    expect(computeAmsMapping(reqs, h3cStatus())).toEqual([34]);
+  });
+});

+ 25 - 0
frontend/src/__tests__/pages/InventoryPageSpoolmanLocation.test.tsx

@@ -155,6 +155,31 @@ describe('InventoryPage - LOCATION column (Spoolman mode)', () => {
     });
     });
   });
   });
 
 
+  it('labels an H3C slot 3H-n and a per-hotend external input Ext-n', async () => {
+    server.use(
+      http.get('/api/v1/settings/spoolman', () =>
+        HttpResponse.json({ spoolman_enabled: 'true', spoolman_url: 'http://localhost:7912' })
+      ),
+      http.get('/api/v1/spoolman/inventory/spools', () =>
+        HttpResponse.json([mockSpoolmanSpool, { ...mockSpoolmanSpool, id: 217, color_name: 'Blue', rgba: '0000FFFF' }])
+      ),
+      http.get('/api/v1/spoolman/inventory/slot-assignments/all', () =>
+        HttpResponse.json([
+          // AMS 3H slot 7 is stored as (32, 6); input 250 (hotend 5) as (255, 5).
+          { printer_id: 2, printer_name: 'Workshop', ams_id: 32, tray_id: 6, spoolman_spool_id: 216, ams_label: null, printer_model: 'H3C' },
+          { printer_id: 2, printer_name: 'Workshop', ams_id: 255, tray_id: 5, spoolman_spool_id: 217, ams_label: null, printer_model: 'H3C' },
+        ])
+      ),
+    );
+
+    const { container } = render(<InventoryPageRouter />);
+
+    await waitFor(() => {
+      expect(container.textContent).toContain('Workshop 3H-7');
+      expect(container.textContent).toContain('Workshop Ext-6');
+    });
+  });
+
   it('shows "-" in LOCATION column when spoolman spool has no slot assignment', async () => {
   it('shows "-" in LOCATION column when spoolman spool has no slot assignment', async () => {
     server.use(
     server.use(
       http.get('/api/v1/settings/spoolman', () =>
       http.get('/api/v1/settings/spoolman', () =>

+ 155 - 0
frontend/src/__tests__/pages/PrintersPageAms3h.test.tsx

@@ -0,0 +1,155 @@
+/**
+ * The H3C's AMS 3H on the printer card: ONE unit (id 32, AMS type 6) with
+ * nine slots, numbered as Bambu Studio does (global trays 28-36), and the
+ * H3C's per-hotend external inputs 247-255 drawn compactly.
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, waitFor, fireEvent } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { PrintersPage } from '../../pages/PrintersPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const mockPrinter = {
+  id: 2,
+  name: 'Workshop H3C',
+  ip_address: '192.168.1.13',
+  serial_number: '32X00000000001',
+  access_code: '12345678',
+  model: 'H3C',
+  enabled: true,
+  nozzle_diameter: 0.4,
+  nozzle_type: 'hardened_steel',
+  location: 'Workshop',
+  auto_archive: true,
+  created_at: '2024-01-01T00:00:00Z',
+  updated_at: '2024-01-01T00:00:00Z',
+};
+
+const tray = (id: number, color: string) => ({
+  id,
+  tray_color: color,
+  tray_type: 'PLA',
+  tray_sub_brands: '',
+  tray_id_name: null,
+  tray_info_idx: 'GFL99',
+  remain: -1,
+  k: null,
+  cali_idx: null,
+  tag_uid: null,
+  tray_uuid: null,
+  nozzle_temp_min: 190,
+  nozzle_temp_max: 230,
+  drying_temp: null,
+  drying_time: null,
+  state: 27,
+  exists: true,
+});
+
+const ams3h = {
+  id: 32,
+  ams_type: 6,
+  humidity: 33,
+  temp: 27.6,
+  is_ams_ht: false,
+  serial_number: 'SN3H',
+  sw_ver: '02.00.32.82',
+  dry_time: 0,
+  dry_status: 0,
+  dry_sub_status: 0,
+  dry_sf_reason: [],
+  module_type: 'n10a',
+  dryer_slots: [5, 7],
+  dryer_humidity: 25,
+  dryer_temp: 28.1,
+  tray: [
+    tray(0, 'DE4343FF'), tray(1, 'FFFFFFFF'), tray(2, '757575FF'), tray(3, '000000FF'), tray(4, '0178BFFF'),
+    tray(5, 'AE96D4FF'), tray(6, 'F7D959FF'), { id: 7, state: 160, exists: false }, tray(8, '61C680FF'),
+  ],
+};
+
+const emptyInputs = [247, 248, 249, 250, 251, 252, 253, 254, 255].map((id) => ({ id, tray_type: '', tray_color: '00000000', state: 1 }));
+
+const h3cStatus = {
+  connected: true,
+  state: 'IDLE',
+  progress: 0,
+  layer_num: 0,
+  total_layers: 0,
+  temperatures: { nozzle: 25, bed: 25, chamber: 25 },
+  remaining_time: 0,
+  filename: null,
+  wifi_signal: -50,
+  speed_level: 2,
+  tray_now: 255,
+  vt_tray: emptyInputs,
+  ams: [ams3h],
+};
+
+describe('PrintersPage - H3C AMS 3H', () => {
+  beforeEach(() => {
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter])),
+      http.get('/api/v1/queue/', () => HttpResponse.json([])),
+    );
+  });
+
+  it('draws one card with the nine slots and its dryer, and no tile per empty input', async () => {
+    server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(h3cStatus)));
+
+    render(<PrintersPage />);
+
+    // Eight loaded slots (slot 8 is empty) behind filament hover cards.
+    await waitFor(() => {
+      expect(screen.getAllByTestId('filament-slot')).toHaveLength(8);
+    });
+    expect(screen.getByText('AMS 3H')).toBeInTheDocument();
+    expect(screen.getByTestId('ams3h-dryer')).toBeInTheDocument();
+    expect(screen.getByTestId('rack-externals-empty')).toHaveTextContent('9');
+  });
+
+  it('loads an AMS 3H slot by its global tray id 28 + slot', async () => {
+    const user = userEvent.setup();
+    let captured: string | null = null;
+    server.use(
+      http.get('/api/v1/printers/:id/status', () => HttpResponse.json(h3cStatus)),
+      http.post('/api/v1/printers/:id/ams/load', ({ request }) => {
+        captured = new URL(request.url).searchParams.get('tray_id');
+        return HttpResponse.json({ success: true, message: 'Loading filament from AMS 3H slot 3' });
+      }),
+    );
+
+    render(<PrintersPage />);
+
+    await waitFor(() => {
+      expect(screen.getAllByTestId('filament-slot')).toHaveLength(8);
+    });
+    fireEvent.mouseEnter(screen.getAllByTestId('filament-slot')[2]);
+    await waitFor(() => {
+      expect(screen.getByText('Load')).toBeInTheDocument();
+    });
+    await user.click(screen.getByText('Load'));
+
+    await waitFor(() => {
+      expect(captured).toBe('30');
+    });
+  });
+
+  it('shows a loaded external input as Ext-n without load or unload', async () => {
+    const inputs = emptyInputs.map((vt) => (vt.id === 250 ? { ...tray(250, 'FF0000FF'), tray_type: 'PETG' } : vt));
+    server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json({ ...h3cStatus, vt_tray: inputs })));
+
+    render(<PrintersPage />);
+
+    await waitFor(() => {
+      expect(screen.getAllByTestId('filament-slot')).toHaveLength(9);
+    });
+    expect(screen.queryByTestId('rack-externals-empty')).not.toBeInTheDocument();
+    // Input 250 feeds hotend 5 (PrintTool 6).
+    const slots = screen.getAllByTestId('filament-slot');
+    expect(slots[8]).toHaveTextContent('6');
+    expect(slots[8]).toHaveTextContent('PETG');
+  });
+});

+ 47 - 0
frontend/src/__tests__/pages/QueuePage.test.tsx

@@ -1163,6 +1163,53 @@ describe('QueuePage', () => {
         expect(await within(row).findByText('Caramel')).toBeInTheDocument();
         expect(await within(row).findByText('Caramel')).toBeInTheDocument();
         expect(within(row).queryByText(/Empty/)).not.toBeInTheDocument();
         expect(within(row).queryByText(/Empty/)).not.toBeInTheDocument();
       });
       });
+
+      // The H3C: its AMS 3H is unit 32 (type 6), slot k is global tray 28 + k,
+      // and it has one external input per hotend, 247-255.
+      const h3cAms = [
+        {
+          id: 32,
+          ams_type: 6,
+          tray: Array.from({ length: 9 }, (_, k) =>
+            k === 6 ? { id: k, tray_type: null, exists: false, state: 160 } : { id: k, tray_type: 'PLA', tray_color: 'DE4343FF', remain: -1 },
+          ),
+        },
+      ];
+      const h3cInputs = Array.from({ length: 9 }, (_, k) => ({ id: 247 + k, tray_type: '' }));
+
+      it('says a mapped AMS 3H slot (28 + slot) is empty, as 3H-n', async () => {
+        useMappedPrinter([34], { connected: true, ams: h3cAms, vt_tray: h3cInputs });
+        const row = await mappedRow();
+
+        await waitFor(() => {
+          expect(within(row).getByText('3H-7 · Empty · Caramel')).toBeInTheDocument();
+        });
+      });
+
+      it('resolves a mapping to a loaded AMS 3H slot', async () => {
+        useMappedPrinter([28], { connected: true, ams: h3cAms, vt_tray: h3cInputs });
+        const row = await mappedRow();
+
+        await waitFor(() => {
+          expect(within(row).getByText(/^3H-1 · PLA · /)).toBeInTheDocument();
+        });
+      });
+
+      it('never calls a rack printer\'s external input empty', async () => {
+        let statusServed = false;
+        useMappedPrinter([250], { connected: true, ams: h3cAms, vt_tray: h3cInputs });
+        server.use(
+          http.get('/api/v1/printers/:id/status', () => {
+            statusServed = true;
+            return HttpResponse.json({ id: 1, name: 'Test Printer', state: 'IDLE', connected: true, ams: h3cAms, vt_tray: h3cInputs, nozzles: [], ams_extruder_map: {} });
+          }),
+        );
+        const row = await mappedRow();
+
+        await waitFor(() => expect(statusServed).toBe(true));
+        expect(await within(row).findByText('Caramel')).toBeInTheDocument();
+        expect(within(row).queryByText(/Empty/)).not.toBeInTheDocument();
+      });
     });
     });
 
 
     it('shows one if-started-now ETA for an eligible pending item', async () => {
     it('shows one if-started-now ETA for an eligible pending item', async () => {

+ 37 - 0
frontend/src/__tests__/pages/SpoolBuddyAmsPage.test.tsx

@@ -476,4 +476,41 @@ describe('SpoolBuddyAmsPage Phase 14 — SlotActionPicker BL-detection in local
 
 
     await screen.findByText('Track a spool from your inventory');
     await screen.findByText('Track a spool from your inventory');
   });
   });
+
+  describe('the H3C (AMS 3H unit 32, per-hotend external inputs)', () => {
+    function h3cStatus(trayNow: number) {
+      return {
+        connected: true,
+        state: 'IDLE',
+        ams: [{
+          id: 32, ams_type: 6, module_type: 'n10a', humidity: 33, temp: 25,
+          tray: Array.from({ length: 9 }, (_, k) => ({ id: k, tray_type: 'PLA', tray_color: 'DE4343FF', remain: -1 })),
+        }],
+        vt_tray: Array.from({ length: 9 }, (_, k) => ({ id: 247 + k, tray_type: k === 3 ? 'PETG' : '' })),
+        tray_now: trayNow,
+        active_extruder: 0,
+      };
+    }
+
+    it('rings the slot tray_now names as 28 + slot', async () => {
+      apiResponses.getPrinterStatus = h3cStatus(34);
+      renderPage();
+
+      const slots = await screen.findAllByTestId('ams-slot');
+      expect(slots).toHaveLength(9);
+      expect(slots[6]).toHaveAttribute('aria-pressed', 'true');
+      expect(slots.filter((slot) => slot.getAttribute('aria-pressed') === 'true')).toHaveLength(1);
+    });
+
+    it('names a slot "AMS 3H Slot n" and the external inputs Ext-n', async () => {
+      apiResponses.getPrinterStatus = h3cStatus(255);
+      renderPage();
+
+      fireEvent.click(await findAmsSlot(7));
+      expect(await screen.findByRole('heading', { name: 'AMS 3H Slot 7' })).toBeInTheDocument();
+      // Input 250 feeds hotend 5: Ext-6. No Ext-L / Ext-R on a single-extruder rack printer.
+      for (let n = 1; n <= 9; n++) expect(screen.getByText(`Ext-${n}`)).toBeInTheDocument();
+      expect(screen.queryByText('Ext-L')).not.toBeInTheDocument();
+    });
+  });
 });
 });

+ 93 - 0
frontend/src/__tests__/pages/SpoolBuddyInventoryAssignmentLabel.test.tsx

@@ -0,0 +1,93 @@
+/**
+ * SpoolBuddy inventory: the AMS location badge of an assigned spool, in both
+ * inventory modes. A rack printer (H3C) names its per-hotend external inputs
+ * "Ext-n", which needs the assignment's printer_model in Spoolman mode too.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { MemoryRouter, Route, Routes, Outlet } from 'react-router-dom';
+import { ToastProvider } from '../../contexts/ToastContext';
+import { SpoolBuddyInventoryPage } from '../../pages/spoolbuddy/SpoolBuddyInventoryPage';
+
+let apiResponses: Record<string, unknown> = {};
+
+vi.mock('../../api/client', () => ({
+  api: new Proxy({} as Record<string, unknown>, {
+    get: (_t, p: string) => () => Promise.resolve(p in apiResponses ? apiResponses[p] : null),
+  }),
+}));
+
+const spool = (id: number) => ({
+  id,
+  material: 'PLA',
+  subtype: 'Basic',
+  brand: 'Bambu Lab',
+  color_name: 'Red',
+  rgba: 'FF0000FF',
+  label_weight: 1000,
+  weight_used: 0,
+  archived_at: null,
+  tag_uid: null,
+  tray_uuid: null,
+  data_origin: 'spoolman',
+  k_profiles: [],
+});
+
+function renderPage() {
+  const context = { selectedPrinterId: 1, sbState: { matchedSpool: null } };
+  function Wrapper() {
+    return <Outlet context={context} />;
+  }
+  const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
+  return render(
+    <ToastProvider>
+      <QueryClientProvider client={qc}>
+        <MemoryRouter initialEntries={['/spoolbuddy/inventory']}>
+          <Routes>
+            <Route element={<Wrapper />}>
+              <Route path="spoolbuddy/inventory" element={<SpoolBuddyInventoryPage />} />
+            </Route>
+          </Routes>
+        </MemoryRouter>
+      </QueryClientProvider>
+    </ToastProvider>,
+  );
+}
+
+describe('SpoolBuddy inventory slot badge', () => {
+  beforeEach(() => {
+    apiResponses = {};
+  });
+
+  it('names an H3C external input Ext-n in Spoolman mode', async () => {
+    apiResponses = {
+      getSpoolmanSettings: { spoolman_enabled: 'true', spoolman_url: 'http://spoolman:7912' },
+      getSpoolmanInventorySpools: [spool(1), spool(2), spool(3)],
+      getSpoolmanSlotAssignments: [
+        // (255, 5) = input 250, which feeds hotend 5: Ext-6.
+        { printer_id: 2, printer_name: 'H3C', ams_id: 255, tray_id: 5, spoolman_spool_id: 1, ams_label: null, printer_model: 'H3C' },
+        { printer_id: 2, printer_name: 'H3C', ams_id: 32, tray_id: 6, spoolman_spool_id: 2, ams_label: null, printer_model: 'H3C' },
+        { printer_id: 1, printer_name: 'X1C', ams_id: 255, tray_id: 0, spoolman_spool_id: 3, ams_label: null, printer_model: 'X1C' },
+      ],
+    };
+    renderPage();
+
+    expect(await screen.findByText('Ext-6')).toBeInTheDocument();
+    expect(screen.getByText('3H-7')).toBeInTheDocument();
+    expect(screen.getByText('Ext')).toBeInTheDocument();
+  });
+
+  it('names an H3C external input Ext-n with the built-in inventory', async () => {
+    apiResponses = {
+      getSpoolmanSettings: { spoolman_enabled: 'false', spoolman_url: '' },
+      getSpools: [spool(1)],
+      getAssignments: [{ id: 1, spool_id: 1, printer_id: 2, printer_name: 'H3C', ams_id: 255, tray_id: 8, printer_model: 'H3C' }],
+    };
+    renderPage();
+
+    // (255, 8) = input 253, hotend 2: Ext-3.
+    expect(await screen.findByText('Ext-3')).toBeInTheDocument();
+  });
+});

+ 129 - 0
frontend/src/__tests__/utils/ams3h.test.ts

@@ -0,0 +1,129 @@
+/**
+ * The H3C's AMS 3H (unit 32, AMS type 6, global trays 28-36) and its
+ * per-hotend external inputs 247-255 ("Ext-n") in the amsHelpers labels and
+ * card layout.
+ */
+import { describe, expect, it } from 'vitest';
+import {
+  ams3hDryerGridColumns,
+  ams3hSlotGridPlacement,
+  amsHasLoadedTray,
+  amsUnitSlotCount,
+  formatAssignmentSlotLabel,
+  formatSlotLabel,
+  getAmsLabel,
+  getGlobalTrayId,
+  isAms3hUnit,
+  preferLowestSortKey,
+  rackExternalLabel,
+  spoolBuddyAmsName,
+} from '../../utils/amsHelpers';
+
+const ams3h = { id: 32, ams_type: 6 };
+
+describe('the AMS 3H unit', () => {
+  it('is told by its AMS type, or by its unit id 32 when the type is not at hand', () => {
+    expect(isAms3hUnit(ams3h)).toBe(true);
+    expect(isAms3hUnit({ id: 32 })).toBe(true);
+    expect(isAms3hUnit({ id: 0, ams_type: 1 })).toBe(false);
+    expect(isAms3hUnit({ id: 128 })).toBe(false);
+    expect(isAms3hUnit(undefined)).toBe(false);
+  });
+
+  it('draws nine slots, a regular AMS four and an AMS-HT one', () => {
+    expect(amsUnitSlotCount(ams3h)).toBe(9);
+    expect(amsUnitSlotCount({ id: 1 })).toBe(4);
+    expect(amsUnitSlotCount({ id: 129 })).toBe(1);
+  });
+
+  it('numbers its slots 28-36, clear of the AMS-HT range', () => {
+    expect([0, 1, 2, 3, 4, 5, 6, 7, 8].map((slot) => getGlobalTrayId(32, slot, false, 6))).toEqual([28, 29, 30, 31, 32, 33, 34, 35, 36]);
+    expect(getGlobalTrayId(32, 6, false)).toBe(34);
+    // Every other kind keeps its numbers.
+    expect(getGlobalTrayId(1, 2, false)).toBe(6);
+    expect(getGlobalTrayId(129, 0, false)).toBe(129);
+    expect(getGlobalTrayId(255, 0, true)).toBe(254);
+    expect(getGlobalTrayId(255, 1, true)).toBe(255);
+  });
+
+  it('sees its slot on the toolhead', () => {
+    const unit = { ...ams3h, tray: [{ id: 6 }, { id: 7 }] };
+    expect(amsHasLoadedTray(unit, 34, undefined)).toBe(true);
+    expect(amsHasLoadedTray(unit, 6, undefined)).toBe(false);
+  });
+
+  it('is labelled AMS 3H and its slots 3H-1..3H-9', () => {
+    expect(getAmsLabel(32)).toBe('AMS 3H');
+    expect(formatSlotLabel(32, 6, false, false)).toBe('3H-7');
+    expect(formatSlotLabel(1, 2, false, false)).toBe('B3');
+    expect(spoolBuddyAmsName(32)).toBe('AMS 3H');
+  });
+
+  it('follows the reported AMS type over the unit id', () => {
+    expect(getAmsLabel(32, 7)).not.toBe('AMS 3H');
+    expect(formatSlotLabel(32, 6, false, false, 7)).not.toBe('3H-7');
+    expect(getAmsLabel(40, 6)).toBe('AMS 3H');
+    expect(formatSlotLabel(40, 0, false, false, 6)).toBe('3H-1');
+    expect(spoolBuddyAmsName(40, 6)).toBe('AMS 3H');
+  });
+
+  it('leaves the SpoolBuddy names of every other unit as they were', () => {
+    expect(spoolBuddyAmsName(0)).toBe('AMS A');
+    expect(spoolBuddyAmsName(3)).toBe('AMS D');
+    expect(spoolBuddyAmsName(129)).toBe('AMS HT B');
+    expect(spoolBuddyAmsName(6)).toBe('AMS 6');
+  });
+
+  it('sorts its slots by position for the prefer-lowest tie-break', () => {
+    const key = (trayId: number) => preferLowestSortKey({ globalTrayId: 28 + trayId, amsId: 32, trayId }, undefined)[2];
+    expect(key(0)).toBeLessThan(key(8));
+    expect(key(8)).toBeLessThan(preferLowestSortKey({ globalTrayId: 128, amsId: 128, trayId: 0 }, undefined)[2]);
+  });
+});
+
+describe('slot labels outside the printer card', () => {
+  it('labels inventory assignments on an H3C by slot and by hotend input', () => {
+    expect(formatAssignmentSlotLabel({ ams_id: 32, tray_id: 6, printer_model: 'H3C' })).toBe('3H-7');
+    expect(formatAssignmentSlotLabel({ ams_id: 32, tray_id: 8 })).toBe('3H-9');
+    expect(formatAssignmentSlotLabel({ ams_id: 1, tray_id: 2, printer_model: 'X1C' })).toBe('B3');
+    // External inputs: (255, 1) = 255 feeds hotend 0 -> Ext-1, (255, 5) = 250 -> Ext-6.
+    expect(formatAssignmentSlotLabel({ ams_id: 255, tray_id: 1, printer_model: 'H3C' })).toBe('Ext-1');
+    expect(formatAssignmentSlotLabel({ ams_id: 255, tray_id: 5, printer_model: 'H3C' })).toBe('Ext-6');
+    expect(formatAssignmentSlotLabel({ ams_id: 255, tray_id: 0, printer_model: 'X1C' })).toBe('Ext');
+    expect(formatAssignmentSlotLabel({ ams_id: 255, tray_id: 1 })).toBe('Ext');
+    expect(formatAssignmentSlotLabel({ ams_id: 255, tray_id: 8, printer_model: 'N10' })).toBe('Ext-3');
+    expect(formatAssignmentSlotLabel({ ams_id: 129, tray_id: 0, printer_model: 'H3C' })).toBe('HT-B');
+  });
+
+  it('numbers a per-hotend input like Bambu Studio: Ext-n feeds PrintTool n', () => {
+    expect(rackExternalLabel(255)).toBe('Ext-1');
+    expect(rackExternalLabel(247)).toBe('Ext-9');
+  });
+});
+
+describe('AMS 3H card layout', () => {
+  it('puts 2, 4, 6, 8 in the back row and 1-9 odd in front, staggered, as on the unit', () => {
+    const rows = [1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => [n, ams3hSlotGridPlacement(n)]);
+    expect(rows.filter(([, p]) => (p as { gridRow: number }).gridRow === 1).map(([n]) => n)).toEqual([2, 4, 6, 8]);
+    expect(rows.filter(([, p]) => (p as { gridRow: number }).gridRow === 2).map(([n]) => n)).toEqual([1, 3, 5, 7, 9]);
+    // Each back slot starts half a slot after the front slot before it.
+    expect(ams3hSlotGridPlacement(1).gridColumn).toBe('1 / span 2');
+    expect(ams3hSlotGridPlacement(2).gridColumn).toBe('2 / span 2');
+    expect(ams3hSlotGridPlacement(9).gridColumn).toBe('9 / span 2');
+  });
+
+  it('boxes the dryer around slots 6 and 8 with its readings in the free column after slot 8', () => {
+    // As captured: zone_slots [5, 7] (tray ids 0-8) = slots 6 and 8.
+    expect(ams3hDryerGridColumns([5, 7])).toEqual({ start: 6, end: 11, info: 10 });
+  });
+
+  it('draws no readings column when the dryer does not end at slot 8', () => {
+    expect(ams3hDryerGridColumns([1, 3])).toEqual({ start: 2, end: 6, info: null });
+  });
+
+  it('draws no dryer for no slots or front-row slots', () => {
+    expect(ams3hDryerGridColumns(undefined)).toBeNull();
+    expect(ams3hDryerGridColumns([])).toBeNull();
+    expect(ams3hDryerGridColumns([4, 6])).toBeNull(); // slots 5 and 7 are in front
+  });
+});

+ 16 - 0
frontend/src/__tests__/utils/maintenanceWikiUrls.test.ts

@@ -123,4 +123,20 @@ describe('getMaintenanceWikiUrl', () => {
       expect(getMaintenanceWikiUrl('Lubricate Steel Rods', '')).toBeNull();
       expect(getMaintenanceWikiUrl('Lubricate Steel Rods', '')).toBeNull();
     });
     });
   });
   });
+
+  describe('H3C', () => {
+    it('links belt tension to the H3C guide', () => {
+      expect(getMaintenanceWikiUrl('Check Belt Tension', 'H3C')).toBe(
+        'https://wiki.bambulab.com/en/h3c/maintenance/belt_tension',
+      );
+      expect(getMaintenanceWikiUrl('Check Belt Tension', 'N10')).toBe(
+        'https://wiki.bambulab.com/en/h3c/maintenance/belt_tension',
+      );
+    });
+
+    it('does not take the H2 or carbon-rod pages', () => {
+      expect(getMaintenanceWikiUrl('Clean Carbon Rods', 'H3C')).toBeNull();
+      expect(getMaintenanceWikiUrl('Lubricate Linear Rails', 'H3C')).toBeNull();
+    });
+  });
 });
 });

+ 12 - 0
frontend/src/__tests__/utils/printer.test.ts

@@ -12,6 +12,18 @@ import { getPrinterImage, isGcodeCompatible, filterCompatibleQueueItems } from '
 import type { PrintQueueItem } from '../../api/client';
 import type { PrintQueueItem } from '../../api/client';
 
 
 describe('getPrinterImage', () => {
 describe('getPrinterImage', () => {
+  describe('H3C', () => {
+    it('uses its own image, by name or model code', () => {
+      expect(getPrinterImage('H3C')).toBe('/img/printers/h3c.png');
+      expect(getPrinterImage('h3c')).toBe('/img/printers/h3c.png');
+      expect(getPrinterImage('N10')).toBe('/img/printers/h3c.png');
+    });
+
+    it('does not take the H2C image', () => {
+      expect(getPrinterImage('H2C')).toBe('/img/printers/h2c.png');
+    });
+  });
+
   describe('X2D (#988)', () => {
   describe('X2D (#988)', () => {
     it('resolves display name "X2D" to x2d.png', () => {
     it('resolves display name "X2D" to x2d.png', () => {
       expect(getPrinterImage('X2D')).toBe('/img/printers/x2d.png');
       expect(getPrinterImage('X2D')).toBe('/img/printers/x2d.png');

+ 1 - 0
frontend/src/__tests__/utils/printerModel.test.ts

@@ -14,6 +14,7 @@ describe('mapModelCode', () => {
     ['N7', 'P2S'],
     ['N7', 'P2S'],
     ['N6', 'X2D'],
     ['N6', 'X2D'],
     ['O1D', 'H2D'],
     ['O1D', 'H2D'],
+    ['N10', 'H3C'],
     ['N2S', 'A1'],
     ['N2S', 'A1'],
     ['N1', 'A1 Mini'],
     ['N1', 'A1 Mini'],
     ['A11', 'A1'],
     ['A11', 'A1'],

+ 241 - 0
frontend/src/__tests__/utils/trayIds.test.ts

@@ -0,0 +1,241 @@
+/**
+ * Parity with the backend: the cases of backend/tests/unit/test_tray_ids.py
+ * against utils/trayIds (and the amsHelpers wrappers built on it), with the
+ * backend's expected values. Backend-only parts (parsing `info`, raw tray_now,
+ * wire encodings, loaded-tray test, tray bits) have no frontend twin.
+ */
+import { describe, expect, it } from 'vitest';
+import {
+  AmsType,
+  externalHotend,
+  externalSlotIndex,
+  externalTrayId,
+  globalTrayId,
+  isExternalTray,
+  isRackAms,
+  layoutAmsType,
+  locateTray,
+  trayLayoutOf,
+  unitAmsType,
+  type TrayLayout,
+} from '../../utils/trayIds';
+import { getAmsLabel, getGlobalTrayId } from '../../utils/amsHelpers';
+
+// The H3C as the status API reports it.
+const H3C_UNITS = [{ id: 32, ams_type: AmsType.ZMULTI_A }];
+const H3C_VT = Array.from({ length: 9 }, (_, k) => ({ id: 247 + k }));
+const H3C = trayLayoutOf(H3C_UNITS, H3C_VT);
+
+// The backend's TrayLayout.slot_key / tray_from_slot_key, from the frontend pieces.
+function slotKey(layout: TrayLayout, trayId: number): [number, number] | null {
+  if (isExternalTray(trayId, true)) return [255, externalSlotIndex(trayId)];
+  return locateTray(layout, trayId);
+}
+function trayFromSlotKey(layout: TrayLayout, amsId: number, trayId: number): number {
+  return getGlobalTrayId(amsId, trayId, amsId === 255, layoutAmsType(layout, amsId));
+}
+
+describe('AMS type', () => {
+  it('is told from the id without a type', () => {
+    expect(unitAmsType(0)).toBe(AmsType.AMS);
+    expect(unitAmsType(3)).toBe(AmsType.AMS);
+    expect(unitAmsType(128)).toBe(AmsType.N3S);
+    expect(unitAmsType(135)).toBe(AmsType.N3S);
+    expect(unitAmsType(32)).toBe(AmsType.ZMULTI_A);
+  });
+
+  it('takes the reported type over the id, except for an AMS-HT', () => {
+    expect(unitAmsType(32, AmsType.ZMULTI_B)).toBe(AmsType.ZMULTI_B);
+    expect(unitAmsType(128, AmsType.AMS)).toBe(AmsType.N3S);
+    expect(unitAmsType(5, null)).toBe(AmsType.AMS);
+    expect(unitAmsType(5, AmsType.EXT_SPOOL)).toBe(AmsType.AMS);
+  });
+
+  it('knows the rack types', () => {
+    expect(isRackAms(AmsType.ZMULTI_A) && isRackAms(AmsType.ZMULTI_B)).toBe(true);
+    for (const kind of [AmsType.AMS, AmsType.N3F, AmsType.N3S, AmsType.AMS_LITE_MIXED]) {
+      expect(isRackAms(kind)).toBe(false);
+    }
+  });
+});
+
+describe('global tray id', () => {
+  it.each([
+    [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],
+  ])('unit %i slot %i (type %i) is %i', (amsId, slot, kind, expected) => {
+    expect(globalTrayId(amsId, slot, kind)).toBe(expected);
+    expect(getGlobalTrayId(amsId, slot, false, kind)).toBe(expected);
+  });
+
+  it('tells a regular AMS, an AMS-HT and the AMS 3H by their ids without a type', () => {
+    expect([0, 1, 2, 3].map((slot) => globalTrayId(1, slot))).toEqual([4, 5, 6, 7]);
+    expect(globalTrayId(129, 0)).toBe(129);
+    expect(Array.from({ length: 9 }, (_, slot) => globalTrayId(32, slot))).toEqual([28, 29, 30, 31, 32, 33, 34, 35, 36]);
+  });
+
+  it('keeps the AMS 3H out of the AMS-HT and regular ranges', () => {
+    for (let slot = 0; slot < 9; slot++) {
+      const tray = globalTrayId(32, slot);
+      expect(tray < 128 || tray > 135).toBe(true);
+      expect(tray).toBeGreaterThanOrEqual(16);
+    }
+  });
+});
+
+describe('layout', () => {
+  it('reads the H3C as a rack printer', () => {
+    expect(H3C.rack).toBe(true);
+    expect(H3C.units).toEqual([{ id: 32, type: AmsType.ZMULTI_A }]);
+    expect(layoutAmsType(H3C, 32)).toBe(AmsType.ZMULTI_A);
+  });
+
+  it('numbers the H3C both ways', () => {
+    for (let slot = 0; slot < 9; slot++) {
+      const tray = globalTrayId(32, slot, layoutAmsType(H3C, 32));
+      expect(tray).toBe(28 + slot);
+      expect(locateTray(H3C, tray)).toEqual([32, slot]);
+      expect(slotKey(H3C, tray)).toEqual([32, slot]);
+      expect(trayFromSlotKey(H3C, 32, slot)).toBe(tray);
+    }
+  });
+
+  it('numbers a regular printer both ways, unchanged', () => {
+    const layout = trayLayoutOf([{ id: 0, ams_type: AmsType.AMS }, { id: 1, ams_type: AmsType.AMS }, { id: 128 }]);
+    expect(layout.rack).toBe(false);
+    for (const amsId of [0, 1]) {
+      for (let slot = 0; slot < 4; slot++) {
+        const tray = globalTrayId(amsId, slot, layoutAmsType(layout, amsId));
+        expect(tray).toBe(amsId * 4 + slot);
+        expect(locateTray(layout, tray)).toEqual([amsId, slot]);
+        expect(trayFromSlotKey(layout, amsId, slot)).toBe(tray);
+        expect(slotKey(layout, tray)).toEqual([amsId, slot]);
+      }
+    }
+    expect(globalTrayId(128, 0, layoutAmsType(layout, 128))).toBe(128);
+    expect(locateTray(layout, 128)).toEqual([128, 0]);
+  });
+
+  it('agrees both ways without live data', () => {
+    // No AMS data: 28-36 are the AMS 3H's (unit 32), 247-255 external inputs.
+    const empty = trayLayoutOf([]);
+    for (let slot = 0; slot < 9; slot++) {
+      expect(trayFromSlotKey(empty, 32, slot)).toBe(28 + slot);
+      expect(slotKey(empty, 28 + slot)).toEqual([32, slot]);
+    }
+    for (let index = 0; index < 9; index++) {
+      expect(slotKey(empty, trayFromSlotKey(empty, 255, index))).toEqual([255, index]);
+    }
+  });
+
+  it('keeps 28-31 for a listed regular unit 7', () => {
+    const layout = trayLayoutOf([{ id: 7, ams_type: AmsType.AMS }]);
+    expect(slotKey(layout, 28)).toEqual([7, 0]);
+    expect(slotKey(layout, 31)).toEqual([7, 3]);
+    expect(trayFromSlotKey(layout, 7, 2)).toBe(30);
+  });
+
+  it('numbers an AMS-B by its type', () => {
+    const layout = trayLayoutOf([{ id: 40, ams_type: AmsType.ZMULTI_B }]);
+    expect(layout.rack).toBe(true);
+    expect(globalTrayId(40, 2, layoutAmsType(layout, 40))).toBe(39);
+    expect(locateTray(layout, 39)).toEqual([40, 2]);
+  });
+
+  it('takes an explicit type from the API', () => {
+    const layout = trayLayoutOf([{ id: 32, ams_type: 6 }]);
+    expect(globalTrayId(32, 4, layoutAmsType(layout, 32))).toBe(32);
+    expect(locateTray(layout, 32)).toEqual([32, 4]);
+  });
+
+  it('counts a unit listed twice once', () => {
+    const layout = trayLayoutOf([{ id: 32, ams_type: 6 }, { id: 32, ams_type: 6 }]);
+    expect(layout.units).toEqual([{ id: 32, type: AmsType.ZMULTI_A }]);
+    expect(locateTray(layout, 32)).toEqual([32, 4]);
+  });
+});
+
+describe('external inputs', () => {
+  it('are 254 and 255 everywhere', () => {
+    for (const tray of [254, 255]) {
+      expect(isExternalTray(tray)).toBe(true);
+      expect(isExternalTray(tray, true)).toBe(true);
+    }
+  });
+
+  it('are 247-253 only on a rack printer', () => {
+    for (let tray = 247; tray <= 253; tray++) {
+      expect(isExternalTray(tray)).toBe(false);
+      expect(isExternalTray(tray, true)).toBe(true);
+      expect(isExternalTray(tray, H3C.rack)).toBe(true);
+    }
+    expect(isExternalTray(250, trayLayoutOf([{ id: 0 }], [{ id: 254 }, { id: 255 }]).rack)).toBe(false);
+  });
+
+  it('never include an AMS slot', () => {
+    for (const tray of [0, 15, 28, 36, 128]) expect(isExternalTray(tray, true)).toBe(false);
+  });
+
+  it('feed hotend 255 - id', () => {
+    expect([255, 254, 253, 252, 251, 250, 249, 248, 247].map(externalHotend)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8]);
+  });
+
+  it('are stored under ams_id 255 as tray 0-8', () => {
+    expect(externalSlotIndex(254)).toBe(0);
+    expect(externalSlotIndex(255)).toBe(1);
+    expect([247, 248, 249, 250, 251, 252, 253].map(externalSlotIndex)).toEqual([2, 3, 4, 5, 6, 7, 8]);
+    for (let tray = 247; tray <= 255; tray++) {
+      expect(externalTrayId(externalSlotIndex(tray))).toBe(tray);
+      expect(slotKey(H3C, tray)).toEqual([255, externalSlotIndex(tray)]);
+      expect(trayFromSlotKey(H3C, 255, externalSlotIndex(tray))).toBe(tray);
+    }
+    expect(locateTray(H3C, 250)).toBeNull();
+  });
+
+  it('make a rack printer when one of 247-253 is reported', () => {
+    expect(trayLayoutOf([], H3C_VT).rack).toBe(true);
+    expect(trayLayoutOf([], [{ id: 254 }, { id: 255 }]).rack).toBe(false);
+  });
+
+  it('keep their slot keys on a regular printer', () => {
+    const layout = trayLayoutOf([{ id: 0 }], [{ id: 254 }]);
+    expect(slotKey(layout, 254)).toEqual([255, 0]);
+    expect(slotKey(layout, 255)).toEqual([255, 1]);
+    // 247-253 are only ever external inputs, so they keep their stored key everywhere.
+    expect(slotKey(layout, 250)).toEqual([255, 5]);
+    expect(locateTray(layout, 250)).toBeNull();
+  });
+});
+
+describe('unit labels', () => {
+  it('match the backend', () => {
+    expect(getAmsLabel(0)).toBe('AMS-A');
+    expect(getAmsLabel(129)).toBe('HT-B');
+    expect(getAmsLabel(32)).toBe('AMS 3H');
+  });
+});
+
+describe('model fallback', () => {
+  it('gives an H3C without AMS data its AMS 3H', () => {
+    const ams3h = { units: [{ id: 32, type: AmsType.ZMULTI_A }], rack: true };
+    expect(trayLayoutOf(null, null, 'H3C')).toEqual(ams3h);
+    expect(trayLayoutOf([], [], 'N10')).toEqual(ams3h);
+    expect(slotKey(trayLayoutOf(null, null, 'H3C'), 250)).toEqual([255, 5]);
+    expect(isExternalTray(250, trayLayoutOf(null, null, 'H3C').rack)).toBe(true);
+    expect(trayLayoutOf(null, null, 'X1C').rack).toBe(false);
+    expect(trayLayoutOf(null, null).rack).toBe(false);
+  });
+
+  it('never outranks live units', () => {
+    expect(trayLayoutOf([{ id: 0, ams_type: 1 }], [{ id: 254 }], 'H3C').rack).toBe(false);
+  });
+});

+ 11 - 3
frontend/src/api/client.ts

@@ -557,7 +557,12 @@ export interface AMSUnit {
   dry_sf_reason: number[]; // Cannot-dry reasons (1=InsufficientPower, 8=NeedPluginPower)
   dry_sf_reason: number[]; // Cannot-dry reasons (1=InsufficientPower, 8=NeedPluginPower)
   dry_target_temp: number | null; // Active-cycle target °C (Bambu does not echo)
   dry_target_temp: number | null; // Active-cycle target °C (Bambu does not echo)
   dry_filament: string | null;    // Active-cycle filament name we sent
   dry_filament: string | null;    // Active-cycle filament name we sent
-  module_type: string;    // "ams", "ams_f1", "n3f", "n3s"
+  module_type: string;    // "ams", "ams_f1", "n3f", "n3s", "n10a" (H3C AMS 3H)
+  ams_type?: number | null;  // Bambu Studio's AMS type (utils/trayIds AmsType), 6 = AMS 3H
+  // The AMS 3H dryer: its two slots (tray ids 0-8) and that zone's humidity (%) and temperature.
+  dryer_slots?: number[];
+  dryer_humidity?: number | null;
+  dryer_temp?: number | null;
 }
 }
 
 
 export interface ScheduledDrying {
 export interface ScheduledDrying {
@@ -602,6 +607,7 @@ export interface NozzleRackSlot {
   filament_color: string;  // RGBA hex ("00000000" = no filament)
   filament_color: string;  // RGBA hex ("00000000" = no filament)
   filament_id: string;
   filament_id: string;
   filament_type: string;  // Material type (e.g. "PLA", "PETG")
   filament_type: string;  // Material type (e.g. "PLA", "PETG")
+  addr_id?: number | null;  // H3C: home rack position 0-8 (the mounted hotend reports id 0)
 }
 }
 
 
 export interface PrintOptions {
 export interface PrintOptions {
@@ -748,7 +754,7 @@ export interface PrinterStatus {
   // switched to (AMS Filament Backup). Reset at the start of each print.
   // switched to (AMS Filament Backup). Reset at the start of each print.
   tray_change_log?: [number, number][];
   tray_change_log?: [number, number][];
   // Runout / filament-replacement guidance (#2587). Populated only while PAUSED.
   // Runout / filament-replacement guidance (#2587). Populated only while PAUSED.
-  // Global tray IDs (ams_id*4+slot, 128-135 = AMS-HT, 254 = external), matching
+  // Global tray IDs (ams_id*4+slot, 28-36 = H3C AMS 3H, 128-135 = AMS-HT, 254 = external; utils/trayIds), matching
   // the same numbering as tray_now so the AMS graphic can highlight them.
   // the same numbering as tray_now so the AMS graphic can highlight them.
   //   expected_tray = the slot the firmware now expects filament in (null = idle,
   //   expected_tray = the slot the firmware now expects filament in (null = idle,
   //                   not paused, or unresolvable → "check the printer").
   //                   not paused, or unresolvable → "check the printer").
@@ -4372,6 +4378,7 @@ export interface SpoolAssignment {
   pending_config?: boolean;  // Slot was empty at assign time; will configure on insert
   pending_config?: boolean;  // Slot was empty at assign time; will configure on insert
   created_at: string;
   created_at: string;
   ams_label?: string | null;  // User-defined friendly name for the AMS unit
   ams_label?: string | null;  // User-defined friendly name for the AMS unit
+  printer_model?: string | null;  // names a rack printer's external inputs "Ext-n" (utils/trayIds)
 }
 }
 
 
 export interface FilamentSkuSettings {
 export interface FilamentSkuSettings {
@@ -5639,7 +5646,7 @@ export const api = {
       { method: 'POST' }
       { method: 'POST' }
     ),
     ),
 
 
-  // Load filament from a tray. trayId: 0-15 for AMS (amsId*4+slotId), 128-135 for an
+  // Load filament from a tray. trayId: 0-15 for AMS (amsId*4+slotId), 28-36 for the H3C AMS 3H, 128-135 for an
   // AMS-HT (its unit id), 254 for external spool.
   // AMS-HT (its unit id), 254 for external spool.
   // extruderId (0 = right, 1 = left) names the hotend to feed. Pass it only on a
   // extruderId (0 = right, 1 = left) names the hotend to feed. Pass it only on a
   // printer with a Filament Track Switch — there the AMS is bound to a switch
   // printer with a Filament Track Switch — there the AMS is bound to a switch
@@ -7636,6 +7643,7 @@ export const api = {
       tray_id: number;
       tray_id: number;
       spoolman_spool_id: number;
       spoolman_spool_id: number;
       ams_label: string | null;
       ams_label: string | null;
+      printer_model?: string | null;
     }>>(
     }>>(
       printerId !== undefined
       printerId !== undefined
         ? `/spoolman/inventory/slot-assignments/all?printer_id=${printerId}`
         ? `/spoolman/inventory/slot-assignments/all?printer_id=${printerId}`

+ 2 - 0
frontend/src/components/AmsBackupModal.tsx

@@ -20,6 +20,7 @@ import { useTranslation } from 'react-i18next';
 import { Toggle } from './Toggle';
 import { Toggle } from './Toggle';
 import {
 import {
   computeBackupGroups,
   computeBackupGroups,
+  isAms3hUnit,
   isAmsHtUnit,
   isAmsHtUnit,
   normalizeColor,
   normalizeColor,
   type AmsUnitLike,
   type AmsUnitLike,
@@ -43,6 +44,7 @@ interface AmsBackupModalProps {
  * counts toward readability.
  * counts toward readability.
  */
  */
 function formatSlotLabel(amsId: number, slotIdx: number): string {
 function formatSlotLabel(amsId: number, slotIdx: number): string {
+  if (isAms3hUnit({ id: amsId })) return `3H·${slotIdx + 1}`;
   const isHt = isAmsHtUnit(amsId);
   const isHt = isAmsHtUnit(amsId);
   const normalizedId = amsId >= 128 ? amsId - 128 : amsId;
   const normalizedId = amsId >= 128 ? amsId - 128 : amsId;
   const letter = String.fromCharCode(65 + normalizedId);
   const letter = String.fromCharCode(65 + normalizedId);

+ 11 - 7
frontend/src/components/QrAssignTargetModal.tsx

@@ -8,7 +8,8 @@ import { MaterialMismatchConfirm } from './MaterialMismatchConfirm';
 import { QrSpoolScanner } from './QrSpoolScanner';
 import { QrSpoolScanner } from './QrSpoolScanner';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { useUiSettings } from '../hooks/useUiSettings';
 import { useUiSettings } from '../hooks/useUiSettings';
-import { formatSlotLabel, getEmptySlotKind } from '../utils/amsHelpers';
+import { formatSlotLabel, getEmptySlotKind, rackExternalLabel } from '../utils/amsHelpers';
+import { externalSlotIndex, externalTrayId, trayLayoutOf } from '../utils/trayIds';
 import { findMaterialMismatch, type MaterialMismatch } from '../utils/assignMismatch';
 import { findMaterialMismatch, type MaterialMismatch } from '../utils/assignMismatch';
 import { getSwatchStyle } from '../utils/colors';
 import { getSwatchStyle } from '../utils/colors';
 import { inventoryLocationsQueryKey, invalidateInventoryLocations } from '../utils/inventoryQueries';
 import { inventoryLocationsQueryKey, invalidateInventoryLocations } from '../utils/inventoryQueries';
@@ -70,8 +71,10 @@ function targetLocationLabel(target: AssignTarget): string {
     : target.locationName;
     : target.locationName;
 }
 }
 
 
-function externalLabel(t: (k: string) => string, vtCount: number, trayId: number): string {
-  // Match PrintersPage: Ext-L / Ext-R when two external slots, "External" otherwise.
+function externalLabel(t: (k: string) => string, vtCount: number, trayId: number, rack = false): string {
+  // Match PrintersPage: Ext-n on a rack printer, Ext-L / Ext-R when two
+  // external slots, "External" otherwise.
+  if (rack) return rackExternalLabel(externalTrayId(trayId));
   if (vtCount > 1) return t(trayId === 0 ? 'printers.extL' : 'printers.extR');
   if (vtCount > 1) return t(trayId === 0 ? 'printers.extL' : 'printers.extR');
   return t('printers.external');
   return t('printers.external');
 }
 }
@@ -156,17 +159,18 @@ export function QrAssignTargetModal({ isOpen, onClose, spoolmanMode = false }: Q
           trayId: tray.id,
           trayId: tray.id,
           isHt: ams.is_ams_ht,
           isHt: ams.is_ams_ht,
           isExternal: false,
           isExternal: false,
-          label: formatSlotLabel(ams.id, tray.id, ams.is_ams_ht, false),
+          label: formatSlotLabel(ams.id, tray.id, ams.is_ams_ht, false, ams.ams_type),
           tray,
           tray,
         });
         });
       }
       }
     }
     }
     // External (vt_tray) slots: backend keys them under ams_id 255 with
     // External (vt_tray) slots: backend keys them under ams_id 255 with
-    // tray_id = id - 254 (mirrors PrintersPage).
+    // tray_id = id - 254 (mirrors PrintersPage; utils/trayIds externalSlotIndex).
     const externals = status.vt_tray ?? [];
     const externals = status.vt_tray ?? [];
+    const rack = trayLayoutOf(status.ams, externals).rack;
     for (const vt of externals) {
     for (const vt of externals) {
-      const trayId = (vt.id ?? 254) - 254;
-      out.push({ amsId: 255, trayId, isHt: false, isExternal: true, label: externalLabel(t, externals.length, trayId), tray: vt });
+      const trayId = externalSlotIndex(vt.id ?? 254);
+      out.push({ amsId: 255, trayId, isHt: false, isExternal: true, label: externalLabel(t, externals.length, trayId, rack), tray: vt });
     }
     }
     return out;
     return out;
   }, [status, t]);
   }, [status, t]);

+ 5 - 10
frontend/src/components/spoolbuddy/AmsUnitCard.tsx

@@ -1,6 +1,6 @@
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import type { AMSUnit, AMSTray } from '../../api/client';
 import type { AMSUnit, AMSTray } from '../../api/client';
-import { getEmptySlotKind, getFillBarColor } from '../../utils/amsHelpers';
+import { amsUnitSlotCount, getEmptySlotKind, getFillBarColor, isAms3hUnit, spoolBuddyAmsName } from '../../utils/amsHelpers';
 
 
 function trayColorToCSS(color: string | null): string {
 function trayColorToCSS(color: string | null): string {
   if (!color) return '#808080';
   if (!color) return '#808080';
@@ -11,12 +11,6 @@ function isTrayEmpty(tray: AMSTray): boolean {
   return !tray.tray_type || tray.tray_type === '';
   return !tray.tray_type || tray.tray_type === '';
 }
 }
 
 
-function getAmsName(id: number): string {
-  if (id <= 3) return `AMS ${String.fromCharCode(65 + id)}`;
-  if (id >= 128 && id <= 135) return `AMS HT ${String.fromCharCode(65 + id - 128)}`;
-  return `AMS ${id}`;
-}
-
 // --- SVG Icons (matching PrintersPage Bambu Lab style) ---
 // --- SVG Icons (matching PrintersPage Bambu Lab style) ---
 
 
 function WaterDropEmpty({ className }: { className?: string }) {
 function WaterDropEmpty({ className }: { className?: string }) {
@@ -249,8 +243,9 @@ export function AmsUnitCard({ unit, activeSlot, onConfigureSlot, isDualNozzle, n
   const { t } = useTranslation();
   const { t } = useTranslation();
   const trays = unit.tray || [];
   const trays = unit.tray || [];
   const isHt = unit.is_ams_ht;
   const isHt = unit.is_ams_ht;
-  const slotCount = isHt ? 1 : 4;
-  const amsName = getAmsName(unit.id);
+  const isAms3h = isAms3hUnit(unit);
+  const slotCount = isHt ? 1 : amsUnitSlotCount(unit);
+  const amsName = spoolBuddyAmsName(unit.id, unit.ams_type);
 
 
   return (
   return (
     <div className="bg-bambu-dark-secondary rounded-xl border border-[var(--border-color)] p-3 shadow-sm">
     <div className="bg-bambu-dark-secondary rounded-xl border border-[var(--border-color)] p-3 shadow-sm">
@@ -281,7 +276,7 @@ export function AmsUnitCard({ unit, activeSlot, onConfigureSlot, isDualNozzle, n
       </div>
       </div>
 
 
       {/* Slots grid */}
       {/* Slots grid */}
-      <div className={`grid ${isHt ? 'grid-cols-1 max-w-[100px] mx-auto' : 'grid-cols-4'} gap-2`}>
+      <div className={`grid ${isHt ? 'grid-cols-1 max-w-[100px] mx-auto' : isAms3h ? 'grid-cols-5 md:grid-cols-9' : 'grid-cols-4'} gap-2`}>
         {Array.from({ length: slotCount }).map((_, i) => {
         {Array.from({ length: slotCount }).map((_, i) => {
           const tray = trays[i] || {
           const tray = trays[i] || {
             id: i,
             id: i,

+ 21 - 18
frontend/src/components/spoolbuddy/AssignToAmsModal.tsx

@@ -6,17 +6,12 @@ import { api, type InventorySpool, type PrinterStatus, type AMSTray } from '../.
 import { ConfirmModal } from '../ConfirmModal';
 import { ConfirmModal } from '../ConfirmModal';
 import { AmsUnitCard, NozzleBadge } from './AmsUnitCard';
 import { AmsUnitCard, NozzleBadge } from './AmsUnitCard';
 import type { AmsThresholds } from './AmsUnitCard';
 import type { AmsThresholds } from './AmsUnitCard';
-import { getFillBarColor } from '../../utils/amsHelpers';
+import { getFillBarColor, isAms3hUnit, isAmsHtUnit, rackExternalLabel, spoolBuddyAmsName } from '../../utils/amsHelpers';
+import { EXTERNAL_AMS_ID, externalSlotIndex, externalTrayId, isRackAms, layoutAmsType, trayLayoutOf } from '../../utils/trayIds';
 import { getSwatchStyle, resolveSpoolColorName } from '../../utils/colors';
 import { getSwatchStyle, resolveSpoolColorName } from '../../utils/colors';
 import { spoolSwatchStyle } from './spoolPaint';
 import { spoolSwatchStyle } from './spoolPaint';
 import { useUiSettings } from '../../hooks/useUiSettings';
 import { useUiSettings } from '../../hooks/useUiSettings';
 
 
-function getAmsName(id: number): string {
-  if (id <= 3) return `AMS ${String.fromCharCode(65 + id)}`;
-  if (id >= 128 && id <= 135) return `AMS HT ${String.fromCharCode(65 + id - 128)}`;
-  return `AMS ${id}`;
-}
-
 function isTrayEmpty(tray: AMSTray): boolean {
 function isTrayEmpty(tray: AMSTray): boolean {
   return !tray.tray_type || tray.tray_type === '';
   return !tray.tray_type || tray.tray_type === '';
 }
 }
@@ -253,6 +248,8 @@ export function AssignToAmsModal({
   const regularAms = useMemo(() => amsUnits.filter(u => !u.is_ams_ht), [amsUnits]);
   const regularAms = useMemo(() => amsUnits.filter(u => !u.is_ams_ht), [amsUnits]);
   const htAms = useMemo(() => amsUnits.filter(u => u.is_ams_ht), [amsUnits]);
   const htAms = useMemo(() => amsUnits.filter(u => u.is_ams_ht), [amsUnits]);
   const vtTrays = useMemo(() => [...(status?.vt_tray ?? [])].sort((a, b) => (a.id ?? 254) - (b.id ?? 254)), [status?.vt_tray]);
   const vtTrays = useMemo(() => [...(status?.vt_tray ?? [])].sort((a, b) => (a.id ?? 254) - (b.id ?? 254)), [status?.vt_tray]);
+  const trayLayout = useMemo(() => trayLayoutOf(amsUnits, vtTrays), [amsUnits, vtTrays]);
+  const rackExternals = trayLayout.rack;
   const isDualNozzle = printer?.nozzle_count === 2 || status?.temperatures?.nozzle_2 !== undefined;
   const isDualNozzle = printer?.nozzle_count === 2 || status?.temperatures?.nozzle_2 !== undefined;
 
 
   const cachedAmsExtruderMap = useRef<Record<string, number>>({});
   const cachedAmsExtruderMap = useRef<Record<string, number>>({});
@@ -342,7 +339,7 @@ export function AssignToAmsModal({
 
 
   const getTrayForSlot = useCallback((amsId: number, trayId: number): AMSTray | null => {
   const getTrayForSlot = useCallback((amsId: number, trayId: number): AMSTray | null => {
     if (amsId === 254 || amsId === 255) {
     if (amsId === 254 || amsId === 255) {
-      const extTrayId = amsId === 254 ? 254 : 254 + trayId;
+      const extTrayId = amsId === 254 ? 254 : externalTrayId(trayId);
       return vtTrays.find(t => (t.id ?? 254) === extTrayId) || null;
       return vtTrays.find(t => (t.id ?? 254) === extTrayId) || null;
     }
     }
     const unit = amsUnits.find(u => u.id === amsId);
     const unit = amsUnits.find(u => u.id === amsId);
@@ -350,14 +347,16 @@ export function AssignToAmsModal({
   }, [amsUnits, vtTrays]);
   }, [amsUnits, vtTrays]);
 
 
   const getSlotLocationLabel = useCallback((amsId: number, trayId: number): string => {
   const getSlotLocationLabel = useCallback((amsId: number, trayId: number): string => {
-    if (amsId <= 3) return `${getAmsName(amsId)} ${t('ams.slot', 'Slot')} ${trayId + 1}`;
-    if (amsId >= 128 && amsId <= 135) return getAmsName(amsId);
+    const amsType = layoutAmsType(trayLayout, amsId);
+    if (amsId <= 3 || isRackAms(amsType)) return `${spoolBuddyAmsName(amsId, amsType)} ${t('ams.slot', 'Slot')} ${trayId + 1}`;
+    if (amsId === EXTERNAL_AMS_ID && rackExternals) return rackExternalLabel(externalTrayId(trayId));
+    if (isAmsHtUnit(amsId)) return spoolBuddyAmsName(amsId);
     if (!isDualNozzle) return t('printers.ext', 'Ext');
     if (!isDualNozzle) return t('printers.ext', 'Ext');
     // Current assignment records use AMS 255 with tray 0/1 for Ext-L/Ext-R;
     // Current assignment records use AMS 255 with tray 0/1 for Ext-L/Ext-R;
     // AMS 254 is retained for compatibility with older assignment records.
     // AMS 254 is retained for compatibility with older assignment records.
     if (amsId === 254 || (amsId === 255 && trayId === 0)) return t('printers.extL', 'Ext-L');
     if (amsId === 254 || (amsId === 255 && trayId === 0)) return t('printers.extL', 'Ext-L');
     return t('printers.extR', 'Ext-R');
     return t('printers.extR', 'Ext-R');
-  }, [t, isDualNozzle]);
+  }, [t, isDualNozzle, rackExternals, trayLayout]);
 
 
   const doAssign = useCallback((amsId: number, trayId: number) => {
   const doAssign = useCallback((amsId: number, trayId: number) => {
     setStatusType('info');
     setStatusType('info');
@@ -451,7 +450,7 @@ export function AssignToAmsModal({
       const amsFill = tray.remain != null && tray.remain >= 0 ? tray.remain : null;
       const amsFill = tray.remain != null && tray.remain >= 0 ? tray.remain : null;
       const resolvedInvFill = (invFill === 0 && amsFill !== null && amsFill > 0) ? null : invFill;
       const resolvedInvFill = (invFill === 0 && amsFill !== null && amsFill > 0) ? null : invFill;
       items.push({
       items.push({
-        key: `ht-${unit.id}`, label: getAmsName(unit.id),
+        key: `ht-${unit.id}`, label: spoolBuddyAmsName(unit.id),
         amsId: unit.id, trayId: 0, tray, isEmpty: isTrayEmpty(tray),
         amsId: unit.id, trayId: 0, tray, isEmpty: isTrayEmpty(tray),
         nozzleSide: getNozzleSide(unit.id),
         nozzleSide: getNozzleSide(unit.id),
         effectiveFill: resolvedInvFill ?? amsFill,
         effectiveFill: resolvedInvFill ?? amsFill,
@@ -460,15 +459,17 @@ export function AssignToAmsModal({
 
 
     for (const extTray of vtTrays) {
     for (const extTray of vtTrays) {
       const extTrayId = extTray.id ?? 254;
       const extTrayId = extTray.id ?? 254;
-      const extSlotTrayId = extTrayId - 254;
+      const extSlotTrayId = externalSlotIndex(extTrayId);
       const extInvFill = fillOverrides[`255-${extSlotTrayId}`] ?? null;
       const extInvFill = fillOverrides[`255-${extSlotTrayId}`] ?? null;
       const extAmsFill = extTray.remain != null && extTray.remain >= 0 ? extTray.remain : null;
       const extAmsFill = extTray.remain != null && extTray.remain >= 0 ? extTray.remain : null;
       const extResolvedInvFill = (extInvFill === 0 && extAmsFill !== null && extAmsFill > 0) ? null : extInvFill;
       const extResolvedInvFill = (extInvFill === 0 && extAmsFill !== null && extAmsFill > 0) ? null : extInvFill;
       items.push({
       items.push({
         key: `ext-${extTrayId}`,
         key: `ext-${extTrayId}`,
-        label: isDualNozzle
-          ? (extTrayId === 254 ? t('printers.extL', 'Ext-L') : t('printers.extR', 'Ext-R'))
-          : t('printers.ext', 'Ext'),
+        label: rackExternals
+          ? rackExternalLabel(extTrayId)
+          : isDualNozzle
+            ? (extTrayId === 254 ? t('printers.extL', 'Ext-L') : t('printers.extR', 'Ext-R'))
+            : t('printers.ext', 'Ext'),
         amsId: 255, trayId: extSlotTrayId, tray: extTray,
         amsId: 255, trayId: extSlotTrayId, tray: extTray,
         isEmpty: isTrayEmpty(extTray),
         isEmpty: isTrayEmpty(extTray),
         nozzleSide: isDualNozzle ? (extTrayId === 254 ? 'L' : 'R') : null,
         nozzleSide: isDualNozzle ? (extTrayId === 254 ? 'L' : 'R') : null,
@@ -477,7 +478,7 @@ export function AssignToAmsModal({
     }
     }
 
 
     return items;
     return items;
-  }, [htAms, vtTrays, isDualNozzle, t, getNozzleSide, fillOverrides]);
+  }, [htAms, vtTrays, isDualNozzle, t, getNozzleSide, fillOverrides, rackExternals]);
 
 
   if (!isOpen) return null;
   if (!isOpen) return null;
 
 
@@ -631,8 +632,9 @@ export function AssignToAmsModal({
               {regularAms.length > 0 && (
               {regularAms.length > 0 && (
                 <div className="grid grid-cols-1 xl:grid-cols-2 gap-3">
                 <div className="grid grid-cols-1 xl:grid-cols-2 gap-3">
                   {regularAms.map((unit) => (
                   {regularAms.map((unit) => (
+                    // The AMS 3H takes the full width for its nine slots.
+                    <div key={unit.id} className={isAms3hUnit(unit) ? 'xl:col-span-2' : 'contents'}>
                     <AmsUnitCard
                     <AmsUnitCard
-                      key={unit.id}
                       unit={unit}
                       unit={unit}
                       activeSlot={
                       activeSlot={
                         selectedSlot?.amsId === unit.id
                         selectedSlot?.amsId === unit.id
@@ -647,6 +649,7 @@ export function AssignToAmsModal({
                       thresholds={amsThresholds}
                       thresholds={amsThresholds}
                       fillOverrides={fillOverrides}
                       fillOverrides={fillOverrides}
                     />
                     />
+                    </div>
                   ))}
                   ))}
                 </div>
                 </div>
               )}
               )}

+ 10 - 6
frontend/src/hooks/useFilamentMapping.ts

@@ -12,7 +12,9 @@ import {
   isAmsHtUnit,
   isAmsHtUnit,
   preferLowestSortKey,
   preferLowestSortKey,
   compareSortKeys,
   compareSortKeys,
+  rackExternalLabel,
 } from '../utils/amsHelpers';
 } from '../utils/amsHelpers';
+import { externalSlotIndex, trayLayoutOf } from '../utils/trayIds';
 import type { PrinterStatus, SlotSpoolIdentity } from '../api/client';
 import type { PrinterStatus, SlotSpoolIdentity } from '../api/client';
 
 
 /** Global-tray-id → the identity of the spool Bambuddy has bound to that slot. */
 /** Global-tray-id → the identity of the spool Bambuddy has bound to that slot. */
@@ -60,10 +62,12 @@ export function buildLoadedFilaments(
   // dual-nozzle hardware exposes multiple external feeds) each independently
   // dual-nozzle hardware exposes multiple external feeds) each independently
   // imply dual-nozzle — keep them as fallbacks for any firmware rev that
   // imply dual-nozzle — keep them as fallbacks for any firmware rev that
   // surfaces one signal but not the other. (#1257)
   // surfaces one signal but not the other. (#1257)
+  // A rack printer (H3C) has nine external inputs but one extruder.
+  const rackExternals = trayLayoutOf(printerStatus?.ams, printerStatus?.vt_tray).rack;
   const hasDualNozzle =
   const hasDualNozzle =
     Boolean(printerStatus?.nozzles?.[1]?.nozzle_diameter)
     Boolean(printerStatus?.nozzles?.[1]?.nozzle_diameter)
     || (amsExtruderMap && Object.keys(amsExtruderMap).length > 0)
     || (amsExtruderMap && Object.keys(amsExtruderMap).length > 0)
-    || (printerStatus?.vt_tray?.length ?? 0) > 1;
+    || ((printerStatus?.vt_tray?.length ?? 0) > 1 && !rackExternals);
 
 
   // Add filaments from all AMS units (regular and HT)
   // Add filaments from all AMS units (regular and HT)
   printerStatus?.ams?.forEach((amsUnit) => {
   printerStatus?.ams?.forEach((amsUnit) => {
@@ -71,7 +75,7 @@ export function buildLoadedFilaments(
     amsUnit.tray.forEach((tray) => {
     amsUnit.tray.forEach((tray) => {
       if (tray.tray_type) {
       if (tray.tray_type) {
         const color = normalizeColor(tray.tray_color);
         const color = normalizeColor(tray.tray_color);
-        const globalTrayId = getGlobalTrayId(amsUnit.id, tray.id, false);
+        const globalTrayId = getGlobalTrayId(amsUnit.id, tray.id, false, amsUnit.ams_type);
         const identity = slotSpools?.get(globalTrayId);
         const identity = slotSpools?.get(globalTrayId);
         filaments.push({
         filaments.push({
           type: tray.tray_type,
           type: tray.tray_type,
@@ -81,7 +85,7 @@ export function buildLoadedFilaments(
           trayId: tray.id,
           trayId: tray.id,
           isHt,
           isHt,
           isExternal: false,
           isExternal: false,
-          label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
+          label: formatSlotLabel(amsUnit.id, tray.id, isHt, false, amsUnit.ams_type),
           globalTrayId,
           globalTrayId,
           trayInfoIdx: tray.tray_info_idx || '',
           trayInfoIdx: tray.tray_info_idx || '',
           traySubBrands: tray.tray_sub_brands || '',
           traySubBrands: tray.tray_sub_brands || '',
@@ -101,7 +105,7 @@ export function buildLoadedFilaments(
     if (extTray.tray_type) {
     if (extTray.tray_type) {
       const color = normalizeColor(extTray.tray_color);
       const color = normalizeColor(extTray.tray_color);
       const trayId = extTray.id ?? 254;
       const trayId = extTray.id ?? 254;
-      const hasDualExternal = (printerStatus?.vt_tray?.length ?? 0) > 1;
+      const hasDualExternal = (printerStatus?.vt_tray?.length ?? 0) > 1 && !rackExternals;
       // The external holder's global tray id IS the tray id (254 / 255), which
       // The external holder's global tray id IS the tray id (254 / 255), which
       // is how the backend keys it too — see `_ams_key_to_global`.
       // is how the backend keys it too — see `_ams_key_to_global`.
       const identity = slotSpools?.get(trayId);
       const identity = slotSpools?.get(trayId);
@@ -110,10 +114,10 @@ export function buildLoadedFilaments(
         color,
         color,
         colorName: identity?.color_name?.trim() || getColorName(color, extTray.tray_sub_brands, extTray.tray_info_idx),
         colorName: identity?.color_name?.trim() || getColorName(color, extTray.tray_sub_brands, extTray.tray_info_idx),
         amsId: -1,
         amsId: -1,
-        trayId: trayId - 254,
+        trayId: externalSlotIndex(trayId),
         isHt: false,
         isHt: false,
         isExternal: true,
         isExternal: true,
-        label: hasDualExternal ? (trayId === 254 ? 'Ext-L' : 'Ext-R') : 'External',
+        label: rackExternals ? rackExternalLabel(trayId) : hasDualExternal ? (trayId === 254 ? 'Ext-L' : 'Ext-R') : 'External',
         globalTrayId: trayId,
         globalTrayId: trayId,
         trayInfoIdx: extTray.tray_info_idx || '',
         trayInfoIdx: extTray.tray_info_idx || '',
         traySubBrands: extTray.tray_sub_brands || '',
         traySubBrands: extTray.tray_sub_brands || '',

+ 4 - 0
frontend/src/i18n/locales/de.ts

@@ -421,6 +421,7 @@ export default {
     // Printer card
     // Printer card
     readyToPrint: 'Druckbereit',
     readyToPrint: 'Druckbereit',
     external: 'Extern',
     external: 'Extern',
+    externalInputsEmpty: 'Alle {{count}} externen Eingänge sind leer',
     ext: 'Extern',
     ext: 'Extern',
     extL: 'Ext-L',
     extL: 'Ext-L',
     extR: 'Ext-R',
     extR: 'Ext-R',
@@ -497,6 +498,7 @@ export default {
     activeNozzle: 'Aktiv: {{nozzle}} Düse',
     activeNozzle: 'Aktiv: {{nozzle}} Düse',
     amsSwitchInletTooltip: 'Filamentweiche IN-{{inlet}} ({{side}}) — versorgt beide Düsen',
     amsSwitchInletTooltip: 'Filamentweiche IN-{{inlet}} ({{side}}) — versorgt beide Düsen',
     nozzleRack: 'Düsenhalter',
     nozzleRack: 'Düsenhalter',
+    nozzleRackMounted: 'Am Werkzeugkopf',
     nozzleDocked: 'Angedockt',
     nozzleDocked: 'Angedockt',
     nozzleMounted: 'Montiert',
     nozzleMounted: 'Montiert',
     nozzleActive: 'Aktiv',
     nozzleActive: 'Aktiv',
@@ -570,6 +572,7 @@ export default {
       rfidRereadInitiated: 'RFID-Neueinlesen gestartet',
       rfidRereadInitiated: 'RFID-Neueinlesen gestartet',
       loadInitiated: 'Filament wird geladen…',
       loadInitiated: 'Filament wird geladen…',
       unloadInitiated: 'Filament wird entladen…',
       unloadInitiated: 'Filament wird entladen…',
+      unloadAms3h: 'Slot {{n}} wird entladen. Sobald der Drucker dazu auffordert, das Filament von Hand aus dem AMS 3H zurückziehen.',
       failedToLoad: 'Filament konnte nicht geladen werden',
       failedToLoad: 'Filament konnte nicht geladen werden',
       failedToUnload: 'Filament konnte nicht entladen werden',
       failedToUnload: 'Filament konnte nicht entladen werden',
     },
     },
@@ -776,6 +779,7 @@ export default {
       screenOnly: 'Die AMS-Trocknung kann bei diesem Drucker nur am Bildschirm des Druckers selbst gesteuert werden (Einschränkung von Bambu)',
       screenOnly: 'Die AMS-Trocknung kann bei diesem Drucker nur am Bildschirm des Druckers selbst gesteuert werden (Einschränkung von Bambu)',
       stoppingDrying: 'Trocknung wird gestoppt...',
       stoppingDrying: 'Trocknung wird gestoppt...',
       rotateTray: 'Spule während der Trocknung drehen',
       rotateTray: 'Spule während der Trocknung drehen',
+      ams3hDryerSlots: 'Der AMS 3H trocknet nur die Slots {{a}} und {{b}}',
       rotateUnavailableReason: 'Nicht verfügbar — in diesem AMS ist ein Slot zum Druckkopf hin geladen. Die Spule ist durch den Zuführschlauch blockiert und kann nicht rotieren. Filament zuerst zurückziehen.',
       rotateUnavailableReason: 'Nicht verfügbar — in diesem AMS ist ein Slot zum Druckkopf hin geladen. Die Spule ist durch den Zuführschlauch blockiert und kann nicht rotieren. Filament zuerst zurückziehen.',
       startMode: 'Startzeit',
       startMode: 'Startzeit',
       modeNow: 'Jetzt',
       modeNow: 'Jetzt',

+ 4 - 0
frontend/src/i18n/locales/en.ts

@@ -423,6 +423,7 @@ export default {
     // Printer card
     // Printer card
     readyToPrint: 'Ready to print',
     readyToPrint: 'Ready to print',
     external: 'External',
     external: 'External',
+    externalInputsEmpty: 'All {{count}} external inputs are empty',
     ext: 'Ext',
     ext: 'Ext',
     extL: 'Ext-L',
     extL: 'Ext-L',
     extR: 'Ext-R',
     extR: 'Ext-R',
@@ -502,6 +503,7 @@ export default {
     activeNozzle: 'Active: {{nozzle}} nozzle',
     activeNozzle: 'Active: {{nozzle}} nozzle',
     amsSwitchInletTooltip: 'Filament Track Switch IN-{{inlet}} ({{side}}) — feeds both nozzles',
     amsSwitchInletTooltip: 'Filament Track Switch IN-{{inlet}} ({{side}}) — feeds both nozzles',
     nozzleRack: 'Nozzle Rack',
     nozzleRack: 'Nozzle Rack',
+    nozzleRackMounted: 'On the toolhead',
     nozzleDocked: 'Docked',
     nozzleDocked: 'Docked',
     nozzleMounted: 'Mounted',
     nozzleMounted: 'Mounted',
     nozzleActive: 'Active',
     nozzleActive: 'Active',
@@ -575,6 +577,7 @@ export default {
       rfidRereadInitiated: 'RFID re-read initiated',
       rfidRereadInitiated: 'RFID re-read initiated',
       loadInitiated: 'Loading filament…',
       loadInitiated: 'Loading filament…',
       unloadInitiated: 'Unloading filament…',
       unloadInitiated: 'Unloading filament…',
+      unloadAms3h: 'Unloading slot {{n}}. When the printer asks, pull the filament back out of the AMS 3H by hand.',
       failedToLoad: 'Failed to load filament',
       failedToLoad: 'Failed to load filament',
       failedToUnload: 'Failed to unload filament',
       failedToUnload: 'Failed to unload filament',
     },
     },
@@ -781,6 +784,7 @@ export default {
       screenOnly: 'AMS drying on this printer can only be controlled from the printer\'s own screen (Bambu limitation)',
       screenOnly: 'AMS drying on this printer can only be controlled from the printer\'s own screen (Bambu limitation)',
       stoppingDrying: 'Stopping drying...',
       stoppingDrying: 'Stopping drying...',
       rotateTray: 'Rotate spool during drying',
       rotateTray: 'Rotate spool during drying',
+      ams3hDryerSlots: 'The AMS 3H dries slots {{a}} and {{b}} only',
       rotateUnavailableReason: 'Unavailable — a slot in this AMS is loaded to the toolhead. The spool is locked by the feed tube and cannot rotate. Retract the filament first.',
       rotateUnavailableReason: 'Unavailable — a slot in this AMS is loaded to the toolhead. The spool is locked by the feed tube and cannot rotate. Retract the filament first.',
       startMode: 'Start time',
       startMode: 'Start time',
       modeNow: 'Now',
       modeNow: 'Now',

+ 4 - 0
frontend/src/i18n/locales/es.ts

@@ -421,6 +421,7 @@ export default {
     // Printer card
     // Printer card
     readyToPrint: 'Lista para imprimir',
     readyToPrint: 'Lista para imprimir',
     external: 'Externo',
     external: 'Externo',
+    externalInputsEmpty: 'Las {{count}} entradas externas están vacías',
     ext: 'Externo',
     ext: 'Externo',
     extL: 'Ext-L',
     extL: 'Ext-L',
     extR: 'Ext-R',
     extR: 'Ext-R',
@@ -497,6 +498,7 @@ export default {
     activeNozzle: 'Activa: boquilla {{nozzle}}',
     activeNozzle: 'Activa: boquilla {{nozzle}}',
     amsSwitchInletTooltip: 'Conmutador de ruta de filamento IN-{{inlet}} ({{side}}): alimenta ambas boquillas',
     amsSwitchInletTooltip: 'Conmutador de ruta de filamento IN-{{inlet}} ({{side}}): alimenta ambas boquillas',
     nozzleRack: 'Soporte de boquillas',
     nozzleRack: 'Soporte de boquillas',
+    nozzleRackMounted: 'En el cabezal',
     nozzleDocked: 'Acoplada',
     nozzleDocked: 'Acoplada',
     nozzleMounted: 'Montada',
     nozzleMounted: 'Montada',
     nozzleActive: 'Activa',
     nozzleActive: 'Activa',
@@ -570,6 +572,7 @@ export default {
       rfidRereadInitiated: 'Relectura de RFID iniciada',
       rfidRereadInitiated: 'Relectura de RFID iniciada',
       loadInitiated: 'Cargando filamento…',
       loadInitiated: 'Cargando filamento…',
       unloadInitiated: 'Descargando filamento…',
       unloadInitiated: 'Descargando filamento…',
+      unloadAms3h: 'Descargando la ranura {{n}}. Cuando la impresora lo pida, retira a mano el filamento del AMS 3H.',
       failedToLoad: 'Error al cargar el filamento',
       failedToLoad: 'Error al cargar el filamento',
       failedToUnload: 'Error al descargar el filamento',
       failedToUnload: 'Error al descargar el filamento',
     },
     },
@@ -776,6 +779,7 @@ export default {
       screenOnly: 'En esta impresora, el secado del AMS solo se puede controlar desde la pantalla de la propia impresora (limitación de Bambu)',
       screenOnly: 'En esta impresora, el secado del AMS solo se puede controlar desde la pantalla de la propia impresora (limitación de Bambu)',
       stoppingDrying: 'Deteniendo el secado...',
       stoppingDrying: 'Deteniendo el secado...',
       rotateTray: 'Girar la bobina durante el secado',
       rotateTray: 'Girar la bobina durante el secado',
+      ams3hDryerSlots: 'El AMS 3H solo seca las ranuras {{a}} y {{b}}',
       rotateUnavailableReason: 'No disponible — un slot de este AMS está cargado hacia el cabezal. La bobina está bloqueada por el tubo de alimentación y no puede girar. Retira el filamento primero.',
       rotateUnavailableReason: 'No disponible — un slot de este AMS está cargado hacia el cabezal. La bobina está bloqueada por el tubo de alimentación y no puede girar. Retira el filamento primero.',
       startMode: 'Hora de inicio',
       startMode: 'Hora de inicio',
       modeNow: 'Ahora',
       modeNow: 'Ahora',

+ 4 - 0
frontend/src/i18n/locales/fr.ts

@@ -421,6 +421,7 @@ export default {
     // Printer card
     // Printer card
     readyToPrint: 'Prête à imprimer',
     readyToPrint: 'Prête à imprimer',
     external: 'Externe',
     external: 'Externe',
+    externalInputsEmpty: 'Les {{count}} entrées externes sont vides',
     ext: 'Externe',
     ext: 'Externe',
     extL: 'Ext-L',
     extL: 'Ext-L',
     extR: 'Ext-R',
     extR: 'Ext-R',
@@ -497,6 +498,7 @@ export default {
     activeNozzle: 'Active : buse {{nozzle}}',
     activeNozzle: 'Active : buse {{nozzle}}',
     amsSwitchInletTooltip: 'Sélecteur de chemin de filament IN-{{inlet}} ({{side}}) — alimente les deux buses',
     amsSwitchInletTooltip: 'Sélecteur de chemin de filament IN-{{inlet}} ({{side}}) — alimente les deux buses',
     nozzleRack: 'Rack à buses',
     nozzleRack: 'Rack à buses',
+    nozzleRackMounted: 'Sur la tête d\'outil',
     nozzleDocked: 'Rangée',
     nozzleDocked: 'Rangée',
     nozzleMounted: 'Montée',
     nozzleMounted: 'Montée',
     nozzleActive: 'Actif',
     nozzleActive: 'Actif',
@@ -570,6 +572,7 @@ export default {
       rfidRereadInitiated: 'Lecture RFID initiée',
       rfidRereadInitiated: 'Lecture RFID initiée',
       loadInitiated: 'Chargement du filament…',
       loadInitiated: 'Chargement du filament…',
       unloadInitiated: 'Déchargement du filament…',
       unloadInitiated: 'Déchargement du filament…',
+      unloadAms3h: 'Déchargement de l\'emplacement {{n}}. Quand l\'imprimante le demande, retirez le filament de l\'AMS 3H à la main.',
       failedToLoad: 'Échec du chargement du filament',
       failedToLoad: 'Échec du chargement du filament',
       failedToUnload: 'Échec du déchargement du filament',
       failedToUnload: 'Échec du déchargement du filament',
     },
     },
@@ -776,6 +779,7 @@ export default {
       screenOnly: 'Sur cette imprimante, le séchage de l\'AMS ne peut être commandé que depuis l\'écran de l\'imprimante (limitation de Bambu)',
       screenOnly: 'Sur cette imprimante, le séchage de l\'AMS ne peut être commandé que depuis l\'écran de l\'imprimante (limitation de Bambu)',
       stoppingDrying: 'Arrêt du séchage...',
       stoppingDrying: 'Arrêt du séchage...',
       rotateTray: 'Tourner la bobine pendant le séchage',
       rotateTray: 'Tourner la bobine pendant le séchage',
+      ams3hDryerSlots: 'L\'AMS 3H ne sèche que les emplacements {{a}} et {{b}}',
       rotateUnavailableReason: 'Indisponible — un emplacement de cet AMS est chargé vers la tête d\'impression. La bobine est bloquée par le tube d\'alimentation et ne peut pas tourner. Rétractez d\'abord le filament.',
       rotateUnavailableReason: 'Indisponible — un emplacement de cet AMS est chargé vers la tête d\'impression. La bobine est bloquée par le tube d\'alimentation et ne peut pas tourner. Rétractez d\'abord le filament.',
       startMode: 'Heure de démarrage',
       startMode: 'Heure de démarrage',
       modeNow: 'Maintenant',
       modeNow: 'Maintenant',

+ 4 - 0
frontend/src/i18n/locales/it.ts

@@ -421,6 +421,7 @@ export default {
     // Printer card
     // Printer card
     readyToPrint: 'Pronta a stampare',
     readyToPrint: 'Pronta a stampare',
     external: 'Esterna',
     external: 'Esterna',
+    externalInputsEmpty: 'Tutti i {{count}} ingressi esterni sono vuoti',
     ext: 'Esterno',
     ext: 'Esterno',
     extL: 'Ext-L',
     extL: 'Ext-L',
     extR: 'Ext-R',
     extR: 'Ext-R',
@@ -497,6 +498,7 @@ export default {
     activeNozzle: 'Attivo: ugello {{nozzle}}',
     activeNozzle: 'Attivo: ugello {{nozzle}}',
     amsSwitchInletTooltip: 'Filament Track Switch IN-{{inlet}} ({{side}}) — alimenta entrambi gli ugelli',
     amsSwitchInletTooltip: 'Filament Track Switch IN-{{inlet}} ({{side}}) — alimenta entrambi gli ugelli',
     nozzleRack: 'Rack Ugelli',
     nozzleRack: 'Rack Ugelli',
+    nozzleRackMounted: 'Sulla testina',
     nozzleDocked: 'Agganciato',
     nozzleDocked: 'Agganciato',
     nozzleMounted: 'Montato',
     nozzleMounted: 'Montato',
     nozzleActive: 'Attivo',
     nozzleActive: 'Attivo',
@@ -570,6 +572,7 @@ export default {
       rfidRereadInitiated: 'Rilettura RFID avviata',
       rfidRereadInitiated: 'Rilettura RFID avviata',
       loadInitiated: 'Caricamento filamento…',
       loadInitiated: 'Caricamento filamento…',
       unloadInitiated: 'Scaricamento filamento…',
       unloadInitiated: 'Scaricamento filamento…',
+      unloadAms3h: 'Scaricamento dello slot {{n}}. Quando la stampante lo chiede, estrai a mano il filamento dall\'AMS 3H.',
       failedToLoad: 'Caricamento filamento fallito',
       failedToLoad: 'Caricamento filamento fallito',
       failedToUnload: 'Scaricamento filamento fallito',
       failedToUnload: 'Scaricamento filamento fallito',
     },
     },
@@ -776,6 +779,7 @@ export default {
       screenOnly: 'Su questa stampante l\'essiccazione dell\'AMS può essere comandata solo dallo schermo della stampante (limitazione di Bambu)',
       screenOnly: 'Su questa stampante l\'essiccazione dell\'AMS può essere comandata solo dallo schermo della stampante (limitazione di Bambu)',
       stoppingDrying: 'Arresto essiccazione...',
       stoppingDrying: 'Arresto essiccazione...',
       rotateTray: 'Ruota la bobina durante l\'essiccazione',
       rotateTray: 'Ruota la bobina durante l\'essiccazione',
+      ams3hDryerSlots: 'L\'AMS 3H asciuga solo gli slot {{a}} e {{b}}',
       rotateUnavailableReason: 'Non disponibile — uno slot di questo AMS è caricato verso la testa di stampa. La bobina è bloccata dal tubo di alimentazione e non può ruotare. Ritrai prima il filamento.',
       rotateUnavailableReason: 'Non disponibile — uno slot di questo AMS è caricato verso la testa di stampa. La bobina è bloccata dal tubo di alimentazione e non può ruotare. Ritrai prima il filamento.',
       startMode: 'Ora di avvio',
       startMode: 'Ora di avvio',
       modeNow: 'Ora',
       modeNow: 'Ora',

+ 4 - 0
frontend/src/i18n/locales/ja.ts

@@ -420,6 +420,7 @@ export default {
     // Printer card
     // Printer card
     readyToPrint: '印刷可能',
     readyToPrint: '印刷可能',
     external: '外部',
     external: '外部',
+    externalInputsEmpty: '{{count}} 個の外部入力はすべて空です',
     ext: '外部',
     ext: '外部',
     extL: 'Ext-L',
     extL: 'Ext-L',
     extR: 'Ext-R',
     extR: 'Ext-R',
@@ -496,6 +497,7 @@ export default {
     activeNozzle: 'アクティブ: {{nozzle}}ノズル',
     activeNozzle: 'アクティブ: {{nozzle}}ノズル',
     amsSwitchInletTooltip: 'フィラメント経路切替器 IN-{{inlet}}({{side}})— 両方のノズルに供給できます',
     amsSwitchInletTooltip: 'フィラメント経路切替器 IN-{{inlet}}({{side}})— 両方のノズルに供給できます',
     nozzleRack: 'ノズルラック',
     nozzleRack: 'ノズルラック',
+    nozzleRackMounted: 'ツールヘッドに装着中',
     nozzleDocked: 'ドッキング中',
     nozzleDocked: 'ドッキング中',
     nozzleMounted: 'マウント中',
     nozzleMounted: 'マウント中',
     nozzleActive: 'アクティブ',
     nozzleActive: 'アクティブ',
@@ -569,6 +571,7 @@ export default {
       rfidRereadInitiated: 'RFID再読み取りを開始しました',
       rfidRereadInitiated: 'RFID再読み取りを開始しました',
       loadInitiated: 'フィラメントをロード中…',
       loadInitiated: 'フィラメントをロード中…',
       unloadInitiated: 'フィラメントをアンロード中…',
       unloadInitiated: 'フィラメントをアンロード中…',
+      unloadAms3h: 'スロット {{n}} をアンロード中です。プリンターに指示されたら、AMS 3H からフィラメントを手で引き戻してください。',
       failedToLoad: 'フィラメントのロードに失敗',
       failedToLoad: 'フィラメントのロードに失敗',
       failedToUnload: 'フィラメントのアンロードに失敗',
       failedToUnload: 'フィラメントのアンロードに失敗',
     },
     },
@@ -775,6 +778,7 @@ export default {
       screenOnly: 'このプリンターでは、AMS の乾燥はプリンター本体の画面からのみ操作できます (Bambu の仕様上の制限)',
       screenOnly: 'このプリンターでは、AMS の乾燥はプリンター本体の画面からのみ操作できます (Bambu の仕様上の制限)',
       stoppingDrying: '乾燥を停止しています...',
       stoppingDrying: '乾燥を停止しています...',
       rotateTray: '乾燥中にスプールを回転',
       rotateTray: '乾燥中にスプールを回転',
+      ams3hDryerSlots: 'AMS 3H で乾燥できるのはスロット {{a}} と {{b}} だけです',
       rotateUnavailableReason: '利用不可 — このAMSのスロットがツールヘッドにロードされています。スプールが供給チューブで固定されているため回転できません。先にフィラメントを引き戻してください。',
       rotateUnavailableReason: '利用不可 — このAMSのスロットがツールヘッドにロードされています。スプールが供給チューブで固定されているため回転できません。先にフィラメントを引き戻してください。',
       startMode: '開始時刻',
       startMode: '開始時刻',
       modeNow: '今すぐ',
       modeNow: '今すぐ',

+ 4 - 0
frontend/src/i18n/locales/ko.ts

@@ -405,6 +405,7 @@ export default {
     },
     },
     readyToPrint: '인쇄 준비 완료',
     readyToPrint: '인쇄 준비 완료',
     external: '외부',
     external: '외부',
+    externalInputsEmpty: '외부 입력 {{count}}개가 모두 비어 있습니다',
     ext: '외부',
     ext: '외부',
     extL: '외부-L',
     extL: '외부-L',
     extR: '외부-R',
     extR: '외부-R',
@@ -474,6 +475,7 @@ export default {
     activeNozzle: '활성: {{nozzle}} 노즐',
     activeNozzle: '활성: {{nozzle}} 노즐',
     amsSwitchInletTooltip: '필라멘트 트랙 스위치 IN-{{inlet}}({{side}}) — 양쪽 노즐에 공급',
     amsSwitchInletTooltip: '필라멘트 트랙 스위치 IN-{{inlet}}({{side}}) — 양쪽 노즐에 공급',
     nozzleRack: '노즐 랙',
     nozzleRack: '노즐 랙',
+    nozzleRackMounted: '툴헤드에 장착됨',
     nozzleDocked: '도킹됨',
     nozzleDocked: '도킹됨',
     nozzleMounted: '장착됨',
     nozzleMounted: '장착됨',
     nozzleActive: '활성',
     nozzleActive: '활성',
@@ -543,6 +545,7 @@ export default {
       rfidRereadInitiated: 'RFID 재읽기가 시작되었습니다',
       rfidRereadInitiated: 'RFID 재읽기가 시작되었습니다',
       loadInitiated: '필라멘트 로딩 중…',
       loadInitiated: '필라멘트 로딩 중…',
       unloadInitiated: '필라멘트 언로딩 중…',
       unloadInitiated: '필라멘트 언로딩 중…',
+      unloadAms3h: '슬롯 {{n}} 언로드 중입니다. 프린터가 요청하면 AMS 3H에서 필라멘트를 손으로 빼내세요.',
       failedToLoad: '필라멘트 로드 실패',
       failedToLoad: '필라멘트 로드 실패',
       failedToUnload: '필라멘트 언로드 실패',
       failedToUnload: '필라멘트 언로드 실패',
       connectionFailedNotAdded: '프린터에 연결할 수 없습니다. IP, 시리얼 번호, 액세스 코드를 확인하고 LAN 전용 모드가 켜져 있는지 확인하세요. 프린터가 추가되지 않았습니다.'
       connectionFailedNotAdded: '프린터에 연결할 수 없습니다. IP, 시리얼 번호, 액세스 코드를 확인하고 LAN 전용 모드가 켜져 있는지 확인하세요. 프린터가 추가되지 않았습니다.'
@@ -738,6 +741,7 @@ export default {
       screenOnly: '이 프린터에서는 AMS 건조를 프린터 자체 화면에서만 제어할 수 있습니다 (Bambu 제한 사항)',
       screenOnly: '이 프린터에서는 AMS 건조를 프린터 자체 화면에서만 제어할 수 있습니다 (Bambu 제한 사항)',
       stoppingDrying: '건조 정지 중...',
       stoppingDrying: '건조 정지 중...',
       rotateTray: '건조 중 스풀 회전',
       rotateTray: '건조 중 스풀 회전',
+      ams3hDryerSlots: 'AMS 3H는 슬롯 {{a}}, {{b}}만 건조합니다',
       rotateUnavailableReason: '사용할 수 없음 — 이 AMS의 슬롯이 툴헤드로 로드되어 있습니다. 스풀이 공급 튜브에 의해 고정되어 회전할 수 없습니다. 먼저 필라멘트를 뺀 후 다시 시도하십시오.',
       rotateUnavailableReason: '사용할 수 없음 — 이 AMS의 슬롯이 툴헤드로 로드되어 있습니다. 스풀이 공급 튜브에 의해 고정되어 회전할 수 없습니다. 먼저 필라멘트를 뺀 후 다시 시도하십시오.',
       startMode: '시작 시간',
       startMode: '시작 시간',
       modeNow: '지금',
       modeNow: '지금',

+ 4 - 0
frontend/src/i18n/locales/nl.ts

@@ -421,6 +421,7 @@ export default {
     // Printer card
     // Printer card
     readyToPrint: 'Klaar om af te drukken',
     readyToPrint: 'Klaar om af te drukken',
     external: 'Extern',
     external: 'Extern',
+    externalInputsEmpty: 'Alle {{count}} externe ingangen zijn leeg',
     ext: 'Extern',
     ext: 'Extern',
     extL: 'Ext-L',
     extL: 'Ext-L',
     extR: 'Ext-R',
     extR: 'Ext-R',
@@ -500,6 +501,7 @@ export default {
     activeNozzle: 'Actief: {{nozzle}}-nozzle',
     activeNozzle: 'Actief: {{nozzle}}-nozzle',
     amsSwitchInletTooltip: 'Filament Track Switch IN-{{inlet}} ({{side}}) — voedt beide nozzles',
     amsSwitchInletTooltip: 'Filament Track Switch IN-{{inlet}} ({{side}}) — voedt beide nozzles',
     nozzleRack: 'Nozzlerek',
     nozzleRack: 'Nozzlerek',
+    nozzleRackMounted: 'Op de printkop',
     nozzleDocked: 'Gedockt',
     nozzleDocked: 'Gedockt',
     nozzleMounted: 'Gemonteerd',
     nozzleMounted: 'Gemonteerd',
     nozzleActive: 'Actief',
     nozzleActive: 'Actief',
@@ -573,6 +575,7 @@ export default {
       rfidRereadInitiated: 'Opnieuw uitlezen van RFID gestart',
       rfidRereadInitiated: 'Opnieuw uitlezen van RFID gestart',
       loadInitiated: 'Filament laden…',
       loadInitiated: 'Filament laden…',
       unloadInitiated: 'Filament ontladen…',
       unloadInitiated: 'Filament ontladen…',
+      unloadAms3h: 'Sleuf {{n}} wordt ontladen. Trek het filament met de hand uit de AMS 3H terug zodra de printer daarom vraagt.',
       failedToLoad: 'Filament laden mislukt',
       failedToLoad: 'Filament laden mislukt',
       failedToUnload: 'Filament ontladen mislukt',
       failedToUnload: 'Filament ontladen mislukt',
     },
     },
@@ -779,6 +782,7 @@ export default {
       screenOnly: 'AMS-drogen op deze printer kan alleen via het eigen scherm van de printer worden bediend (Bambu-beperking)',
       screenOnly: 'AMS-drogen op deze printer kan alleen via het eigen scherm van de printer worden bediend (Bambu-beperking)',
       stoppingDrying: 'Drogen stoppen...',
       stoppingDrying: 'Drogen stoppen...',
       rotateTray: 'Spoel draaien tijdens drogen',
       rotateTray: 'Spoel draaien tijdens drogen',
+      ams3hDryerSlots: 'De AMS 3H droogt alleen sleuven {{a}} en {{b}}',
       rotateUnavailableReason: 'Niet beschikbaar — een sleuf in deze AMS is naar de toolhead geladen. De spoel wordt door de toevoerbuis geblokkeerd en kan niet draaien. Trek eerst het filament terug.',
       rotateUnavailableReason: 'Niet beschikbaar — een sleuf in deze AMS is naar de toolhead geladen. De spoel wordt door de toevoerbuis geblokkeerd en kan niet draaien. Trek eerst het filament terug.',
       startMode: 'Starttijd',
       startMode: 'Starttijd',
       modeNow: 'Nu',
       modeNow: 'Nu',

+ 4 - 0
frontend/src/i18n/locales/pl.ts

@@ -423,6 +423,7 @@ export default {
     // Printer card
     // Printer card
     readyToPrint: 'Gotowa do druku',
     readyToPrint: 'Gotowa do druku',
     external: 'Zewnętrzna',
     external: 'Zewnętrzna',
+    externalInputsEmpty: 'Wszystkie {{count}} wejścia zewnętrzne są puste',
     ext: 'Zewn.',
     ext: 'Zewn.',
     extL: 'Zewn.-L',
     extL: 'Zewn.-L',
     extR: 'Zewn.-P',
     extR: 'Zewn.-P',
@@ -502,6 +503,7 @@ export default {
     activeNozzle: 'Aktywna dysza: {{nozzle}}',
     activeNozzle: 'Aktywna dysza: {{nozzle}}',
     amsSwitchInletTooltip: 'Filament Track Switch IN-{{inlet}} ({{side}}) — zasila obie dysze',
     amsSwitchInletTooltip: 'Filament Track Switch IN-{{inlet}} ({{side}}) — zasila obie dysze',
     nozzleRack: 'Magazyn dysz',
     nozzleRack: 'Magazyn dysz',
+    nozzleRackMounted: 'Na głowicy',
     nozzleDocked: 'Zadokowana',
     nozzleDocked: 'Zadokowana',
     nozzleMounted: 'Zamontowana',
     nozzleMounted: 'Zamontowana',
     nozzleActive: 'Aktywna',
     nozzleActive: 'Aktywna',
@@ -575,6 +577,7 @@ export default {
       rfidRereadInitiated: 'Rozpoczęto ponowny odczyt RFID',
       rfidRereadInitiated: 'Rozpoczęto ponowny odczyt RFID',
       loadInitiated: 'Ładowanie filamentu…',
       loadInitiated: 'Ładowanie filamentu…',
       unloadInitiated: 'Wyładowywanie filamentu…',
       unloadInitiated: 'Wyładowywanie filamentu…',
+      unloadAms3h: 'Wyładowywanie gniazda {{n}}. Gdy drukarka o to poprosi, wyciągnij filament z AMS 3H ręcznie.',
       failedToLoad: 'Nie udało się załadować filamentu',
       failedToLoad: 'Nie udało się załadować filamentu',
       failedToUnload: 'Nie udało się wyładować filamentu',
       failedToUnload: 'Nie udało się wyładować filamentu',
     },
     },
@@ -781,6 +784,7 @@ export default {
       screenOnly: 'Suszeniem AMS na tej drukarce można sterować tylko z ekranu drukarki (ograniczenie Bambu)',
       screenOnly: 'Suszeniem AMS na tej drukarce można sterować tylko z ekranu drukarki (ograniczenie Bambu)',
       stoppingDrying: 'Zatrzymywanie suszenia...',
       stoppingDrying: 'Zatrzymywanie suszenia...',
       rotateTray: 'Obracaj szpulę podczas suszenia',
       rotateTray: 'Obracaj szpulę podczas suszenia',
+      ams3hDryerSlots: 'AMS 3H suszy tylko gniazda {{a}} i {{b}}',
       rotateUnavailableReason: 'Niedostępne — slot w tym AMS jest załadowany do głowicy. Szpula jest zablokowana przez rurkę podającą i nie może się obracać. Najpierw wycofaj filament.',
       rotateUnavailableReason: 'Niedostępne — slot w tym AMS jest załadowany do głowicy. Szpula jest zablokowana przez rurkę podającą i nie może się obracać. Najpierw wycofaj filament.',
       startMode: 'Czas rozpoczęcia',
       startMode: 'Czas rozpoczęcia',
       modeNow: 'Teraz',
       modeNow: 'Teraz',

+ 4 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -421,6 +421,7 @@ export default {
     // Printer card
     // Printer card
     readyToPrint: 'Pronto para imprimir',
     readyToPrint: 'Pronto para imprimir',
     external: 'Externo',
     external: 'Externo',
+    externalInputsEmpty: 'As {{count}} entradas externas estão vazias',
     ext: 'Externo',
     ext: 'Externo',
     extL: 'Ext-L',
     extL: 'Ext-L',
     extR: 'Ext-R',
     extR: 'Ext-R',
@@ -497,6 +498,7 @@ export default {
     activeNozzle: 'Ativo: {{nozzle}} bico',
     activeNozzle: 'Ativo: {{nozzle}} bico',
     amsSwitchInletTooltip: 'Comutador de trajeto de filamento IN-{{inlet}} ({{side}}) — alimenta os dois bicos',
     amsSwitchInletTooltip: 'Comutador de trajeto de filamento IN-{{inlet}} ({{side}}) — alimenta os dois bicos',
     nozzleRack: 'Suporte de bicos',
     nozzleRack: 'Suporte de bicos',
+    nozzleRackMounted: 'Na cabeça de impressão',
     nozzleDocked: 'Acoplado',
     nozzleDocked: 'Acoplado',
     nozzleMounted: 'Montado',
     nozzleMounted: 'Montado',
     nozzleActive: 'Ativo',
     nozzleActive: 'Ativo',
@@ -570,6 +572,7 @@ export default {
       rfidRereadInitiated: 'Releitura de RFID iniciada',
       rfidRereadInitiated: 'Releitura de RFID iniciada',
       loadInitiated: 'Carregando filamento…',
       loadInitiated: 'Carregando filamento…',
       unloadInitiated: 'Descarregando filamento…',
       unloadInitiated: 'Descarregando filamento…',
+      unloadAms3h: 'Descarregando o slot {{n}}. Quando a impressora pedir, puxe o filamento de volta do AMS 3H com a mão.',
       failedToLoad: 'Falha ao carregar filamento',
       failedToLoad: 'Falha ao carregar filamento',
       failedToUnload: 'Falha ao descarregar filamento',
       failedToUnload: 'Falha ao descarregar filamento',
     },
     },
@@ -776,6 +779,7 @@ export default {
       screenOnly: 'Nesta impressora, a secagem do AMS só pode ser controlada pela tela da própria impressora (limitação da Bambu)',
       screenOnly: 'Nesta impressora, a secagem do AMS só pode ser controlada pela tela da própria impressora (limitação da Bambu)',
       stoppingDrying: 'Parando secagem...',
       stoppingDrying: 'Parando secagem...',
       rotateTray: 'Girar o carretel durante a secagem',
       rotateTray: 'Girar o carretel durante a secagem',
+      ams3hDryerSlots: 'O AMS 3H seca apenas os slots {{a}} e {{b}}',
       rotateUnavailableReason: 'Indisponível — um slot deste AMS está carregado em direção ao cabeçote. O carretel está travado pelo tubo de alimentação e não pode girar. Retraia o filamento primeiro.',
       rotateUnavailableReason: 'Indisponível — um slot deste AMS está carregado em direção ao cabeçote. O carretel está travado pelo tubo de alimentação e não pode girar. Retraia o filamento primeiro.',
       startMode: 'Horário de início',
       startMode: 'Horário de início',
       modeNow: 'Agora',
       modeNow: 'Agora',

+ 4 - 0
frontend/src/i18n/locales/ru.ts

@@ -410,6 +410,7 @@ export default {
     },
     },
     readyToPrint: "Готов к печати",
     readyToPrint: "Готов к печати",
     external: "Внешняя подача",
     external: "Внешняя подача",
+    externalInputsEmpty: 'Все {{count}} внешних входов пусты',
     ext: "Внешняя",
     ext: "Внешняя",
     extL: "Внешняя, левое",
     extL: "Внешняя, левое",
     extR: "Внешняя, правое",
     extR: "Внешняя, правое",
@@ -479,6 +480,7 @@ export default {
     activeNozzle: "Активное сопло: {{nozzle}}",
     activeNozzle: "Активное сопло: {{nozzle}}",
     amsSwitchInletTooltip: "Переключатель тракта филамента IN-{{inlet}} ({{side}}) — подаёт на оба сопла",
     amsSwitchInletTooltip: "Переключатель тракта филамента IN-{{inlet}} ({{side}}) — подаёт на оба сопла",
     nozzleRack: "Держатель сопел",
     nozzleRack: "Держатель сопел",
+    nozzleRackMounted: 'На печатающей головке',
     nozzleDocked: "В держателе",
     nozzleDocked: "В держателе",
     nozzleMounted: "Установлено",
     nozzleMounted: "Установлено",
     nozzleActive: "Активно",
     nozzleActive: "Активно",
@@ -549,6 +551,7 @@ export default {
       rfidRereadInitiated: "Запущено повторное считывание RFID",
       rfidRereadInitiated: "Запущено повторное считывание RFID",
       loadInitiated: "Загрузка филамента…",
       loadInitiated: "Загрузка филамента…",
       unloadInitiated: "Выгрузка филамента…",
       unloadInitiated: "Выгрузка филамента…",
+      unloadAms3h: 'Выгрузка слота {{n}}. Когда принтер попросит, вытяните филамент из AMS 3H вручную.',
       failedToLoad: "Не удалось загрузить филамент",
       failedToLoad: "Не удалось загрузить филамент",
       failedToUnload: "Не удалось выгрузить филамент",
       failedToUnload: "Не удалось выгрузить филамент",
     },
     },
@@ -743,6 +746,7 @@ export default {
       screenOnly: "На этом принтере управлять сушкой AMS можно только с экрана самого принтера (ограничение Bambu)",
       screenOnly: "На этом принтере управлять сушкой AMS можно только с экрана самого принтера (ограничение Bambu)",
       stoppingDrying: "Остановка сушки...",
       stoppingDrying: "Остановка сушки...",
       rotateTray: "Вращать катушку во время сушки",
       rotateTray: "Вращать катушку во время сушки",
+      ams3hDryerSlots: 'AMS 3H сушит только слоты {{a}} и {{b}}',
       rotateUnavailableReason: "Недоступно: слот этого AMS сейчас подаёт филамент в печатающую головку. Катушка зафиксирована подающей трубкой и не может вращаться. Сначала выгрузите филамент.",
       rotateUnavailableReason: "Недоступно: слот этого AMS сейчас подаёт филамент в печатающую головку. Катушка зафиксирована подающей трубкой и не может вращаться. Сначала выгрузите филамент.",
       startMode: "Время начала",
       startMode: "Время начала",
       modeNow: "Сейчас",
       modeNow: "Сейчас",

+ 4 - 0
frontend/src/i18n/locales/sv.ts

@@ -421,6 +421,7 @@ export default {
     // Printer card
     // Printer card
     readyToPrint: 'Redo att skriva ut',
     readyToPrint: 'Redo att skriva ut',
     external: 'Extern',
     external: 'Extern',
+    externalInputsEmpty: 'Alla {{count}} externa ingångar är tomma',
     ext: 'Extern',
     ext: 'Extern',
     extL: 'Ext-V',
     extL: 'Ext-V',
     extR: 'Ext-H',
     extR: 'Ext-H',
@@ -500,6 +501,7 @@ export default {
     activeNozzle: 'Aktiv: {{nozzle}} munstycke',
     activeNozzle: 'Aktiv: {{nozzle}} munstycke',
     amsSwitchInletTooltip: 'Filamentspårväxlare IN-{{inlet}} ({{side}}) — matar båda munstyckena',
     amsSwitchInletTooltip: 'Filamentspårväxlare IN-{{inlet}} ({{side}}) — matar båda munstyckena',
     nozzleRack: 'Munstyckesställ',
     nozzleRack: 'Munstyckesställ',
+    nozzleRackMounted: 'På verktygshuvudet',
     nozzleDocked: 'Parkerad',
     nozzleDocked: 'Parkerad',
     nozzleMounted: 'Monterad',
     nozzleMounted: 'Monterad',
     nozzleActive: 'Aktiv',
     nozzleActive: 'Aktiv',
@@ -573,6 +575,7 @@ export default {
       rfidRereadInitiated: 'Omstart av RFID-läsning påbörjad',
       rfidRereadInitiated: 'Omstart av RFID-läsning påbörjad',
       loadInitiated: 'Laddar filament…',
       loadInitiated: 'Laddar filament…',
       unloadInitiated: 'Laddar ur filament…',
       unloadInitiated: 'Laddar ur filament…',
+      unloadAms3h: 'Matar ut fack {{n}}. När skrivaren ber om det drar du ut filamentet ur AMS 3H för hand.',
       failedToLoad: 'Kunde inte ladda filament',
       failedToLoad: 'Kunde inte ladda filament',
       failedToUnload: 'Kunde inte ladda ur filament',
       failedToUnload: 'Kunde inte ladda ur filament',
     },
     },
@@ -779,6 +782,7 @@ export default {
       screenOnly: 'AMS-torkning på denna skrivare kan endast kontrolleras från skrivarens egen skärm (Bambu-begränsning)',
       screenOnly: 'AMS-torkning på denna skrivare kan endast kontrolleras från skrivarens egen skärm (Bambu-begränsning)',
       stoppingDrying: 'Stoppar torkning...',
       stoppingDrying: 'Stoppar torkning...',
       rotateTray: 'Roterar rulle under torkning',
       rotateTray: 'Roterar rulle under torkning',
+      ams3hDryerSlots: 'AMS 3H torkar bara fack {{a}} och {{b}}',
       rotateUnavailableReason: 'Ej tillgänglig — en slot i denna AMS är laddad till verktygshuvudet. Rullen är låst av matarröret och kan inte rotera. Dra tillbaka filamentet först.',
       rotateUnavailableReason: 'Ej tillgänglig — en slot i denna AMS är laddad till verktygshuvudet. Rullen är låst av matarröret och kan inte rotera. Dra tillbaka filamentet först.',
       startMode: 'Starttid',
       startMode: 'Starttid',
       modeNow: 'Nu',
       modeNow: 'Nu',

+ 4 - 0
frontend/src/i18n/locales/tr.ts

@@ -421,6 +421,7 @@ export default {
     // Yazıcı kartı
     // Yazıcı kartı
     readyToPrint: 'Baskıya hazır',
     readyToPrint: 'Baskıya hazır',
     external: 'Harici',
     external: 'Harici',
+    externalInputsEmpty: '{{count}} harici girişin tümü boş',
     ext: 'Harici',
     ext: 'Harici',
     extL: 'Ext-L',
     extL: 'Ext-L',
     extR: 'Ext-R',
     extR: 'Ext-R',
@@ -497,6 +498,7 @@ export default {
     activeNozzle: 'Aktif: {{nozzle}} nozul',
     activeNozzle: 'Aktif: {{nozzle}} nozul',
     amsSwitchInletTooltip: 'Filament Hattı Değiştirici IN-{{inlet}} ({{side}}) — her iki nozulu besler',
     amsSwitchInletTooltip: 'Filament Hattı Değiştirici IN-{{inlet}} ({{side}}) — her iki nozulu besler',
     nozzleRack: 'Nozul Rafı',
     nozzleRack: 'Nozul Rafı',
+    nozzleRackMounted: 'Takım kafasında',
     nozzleDocked: 'Yuvalanmış',
     nozzleDocked: 'Yuvalanmış',
     nozzleMounted: 'Takılı',
     nozzleMounted: 'Takılı',
     nozzleActive: 'Aktif',
     nozzleActive: 'Aktif',
@@ -570,6 +572,7 @@ export default {
       rfidRereadInitiated: 'RFID yeniden okuma başlatıldı',
       rfidRereadInitiated: 'RFID yeniden okuma başlatıldı',
       loadInitiated: 'Filament yükleniyor…',
       loadInitiated: 'Filament yükleniyor…',
       unloadInitiated: 'Filament çıkarılıyor…',
       unloadInitiated: 'Filament çıkarılıyor…',
+      unloadAms3h: '{{n}} numaralı yuva boşaltılıyor. Yazıcı istediğinde filamenti AMS 3H\'ten elle geri çekin.',
       failedToLoad: 'Filament yüklenemedi',
       failedToLoad: 'Filament yüklenemedi',
       failedToUnload: 'Filament çıkarılamadı',
       failedToUnload: 'Filament çıkarılamadı',
     },
     },
@@ -776,6 +779,7 @@ export default {
       screenOnly: 'Bu yazıcıda AMS kurutma yalnızca yazıcının kendi ekranından kontrol edilebilir (Bambu kısıtlaması)',
       screenOnly: 'Bu yazıcıda AMS kurutma yalnızca yazıcının kendi ekranından kontrol edilebilir (Bambu kısıtlaması)',
       stoppingDrying: 'Kurutma durduruluyor...',
       stoppingDrying: 'Kurutma durduruluyor...',
       rotateTray: 'Kurutma sırasında makarayı döndür',
       rotateTray: 'Kurutma sırasında makarayı döndür',
+      ams3hDryerSlots: 'AMS 3H yalnızca {{a}} ve {{b}} numaralı yuvaları kurutur',
       rotateUnavailableReason: 'Kullanılamaz — bu AMS\'nin bir yuvası kafaya doğru yüklenmiş durumda. Makara besleme borusu tarafından kilitlendiği için döndürülemez. Önce filamenti geri çekin.',
       rotateUnavailableReason: 'Kullanılamaz — bu AMS\'nin bir yuvası kafaya doğru yüklenmiş durumda. Makara besleme borusu tarafından kilitlendiği için döndürülemez. Önce filamenti geri çekin.',
       startMode: 'Başlangıç zamanı',
       startMode: 'Başlangıç zamanı',
       modeNow: 'Şimdi',
       modeNow: 'Şimdi',

+ 4 - 0
frontend/src/i18n/locales/uk.ts

@@ -421,6 +421,7 @@ export default {
     // Printer card
     // Printer card
     readyToPrint: "Готовий до друку",
     readyToPrint: "Готовий до друку",
     external: "Зовнішній",
     external: "Зовнішній",
+    externalInputsEmpty: 'Усі {{count}} зовнішніх входів порожні',
     ext: "Зовнішня",
     ext: "Зовнішня",
     extL: "Ext-L",
     extL: "Ext-L",
     extR: "Ext-R",
     extR: "Ext-R",
@@ -500,6 +501,7 @@ export default {
     activeNozzle: "Активне сопло: {{nozzle}}",
     activeNozzle: "Активне сопло: {{nozzle}}",
     amsSwitchInletTooltip: "Перемикач тракту філаменту IN-{{inlet}} ({{side}}) — подає на обидва сопла",
     amsSwitchInletTooltip: "Перемикач тракту філаменту IN-{{inlet}} ({{side}}) — подає на обидва сопла",
     nozzleRack: "Стійка сопел",
     nozzleRack: "Стійка сопел",
+    nozzleRackMounted: 'На друкувальній голівці',
     nozzleDocked: "У док-станції",
     nozzleDocked: "У док-станції",
     nozzleMounted: "Установлено",
     nozzleMounted: "Установлено",
     nozzleActive: "Активний",
     nozzleActive: "Активний",
@@ -573,6 +575,7 @@ export default {
       rfidRereadInitiated: "Розпочато повторне зчитування RFID",
       rfidRereadInitiated: "Розпочато повторне зчитування RFID",
       loadInitiated: "Завантаження філаменту...",
       loadInitiated: "Завантаження філаменту...",
       unloadInitiated: "Вивантаження філаменту...",
       unloadInitiated: "Вивантаження філаменту...",
+      unloadAms3h: 'Вивантаження слота {{n}}. Коли принтер попросить, витягніть філамент з AMS 3H вручну.',
       failedToLoad: "Не вдалося завантажити філамент",
       failedToLoad: "Не вдалося завантажити філамент",
       failedToUnload: "Не вдалося вивантажити філамент",
       failedToUnload: "Не вдалося вивантажити філамент",
     },
     },
@@ -779,6 +782,7 @@ export default {
       screenOnly: "Сушінням AMS на цьому принтері можна керувати лише з екрана принтера (обмеження Bambu)",
       screenOnly: "Сушінням AMS на цьому принтері можна керувати лише з екрана принтера (обмеження Bambu)",
       stoppingDrying: "Припинення сушіння...",
       stoppingDrying: "Припинення сушіння...",
       rotateTray: "Обертати котушку під час сушіння",
       rotateTray: "Обертати котушку під час сушіння",
+      ams3hDryerSlots: 'AMS 3H сушить лише слоти {{a}} і {{b}}',
       rotateUnavailableReason: "Недоступно: філамент із цього слота AMS завантажено в інструментальну головку. Котушка заблокована подавальною трубкою й не може обертатися. Спочатку вивантажте філамент.",
       rotateUnavailableReason: "Недоступно: філамент із цього слота AMS завантажено в інструментальну головку. Котушка заблокована подавальною трубкою й не може обертатися. Спочатку вивантажте філамент.",
       startMode: "Час початку",
       startMode: "Час початку",
       modeNow: "Зараз",
       modeNow: "Зараз",

+ 4 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -421,6 +421,7 @@ export default {
     // Printer card
     // Printer card
     readyToPrint: '准备打印',
     readyToPrint: '准备打印',
     external: '外部',
     external: '外部',
+    externalInputsEmpty: '{{count}} 个外部输入均为空',
     ext: '外置',
     ext: '外置',
     extL: '外置左',
     extL: '外置左',
     extR: '外置右',
     extR: '外置右',
@@ -497,6 +498,7 @@ export default {
     activeNozzle: '当前:{{nozzle}} 喷嘴',
     activeNozzle: '当前:{{nozzle}} 喷嘴',
     amsSwitchInletTooltip: '耗材变轨器 IN-{{inlet}}({{side}})— 可向两个喷嘴供料',
     amsSwitchInletTooltip: '耗材变轨器 IN-{{inlet}}({{side}})— 可向两个喷嘴供料',
     nozzleRack: '喷嘴架',
     nozzleRack: '喷嘴架',
+    nozzleRackMounted: '在工具头上',
     nozzleDocked: '已停靠',
     nozzleDocked: '已停靠',
     nozzleMounted: '已安装',
     nozzleMounted: '已安装',
     nozzleActive: '使用中',
     nozzleActive: '使用中',
@@ -570,6 +572,7 @@ export default {
       rfidRereadInitiated: '已发起 RFID 重新读取',
       rfidRereadInitiated: '已发起 RFID 重新读取',
       loadInitiated: '加载耗材中…',
       loadInitiated: '加载耗材中…',
       unloadInitiated: '卸载耗材中…',
       unloadInitiated: '卸载耗材中…',
+      unloadAms3h: '正在退出槽位 {{n}} 的耗材。打印机提示时,请手动将耗材从 AMS 3H 拉出。',
       failedToLoad: '加载耗材失败',
       failedToLoad: '加载耗材失败',
       failedToUnload: '卸载耗材失败',
       failedToUnload: '卸载耗材失败',
     },
     },
@@ -776,6 +779,7 @@ export default {
       screenOnly: '此打印机的 AMS 干燥只能在打印机自带的屏幕上操作(Bambu 的限制)',
       screenOnly: '此打印机的 AMS 干燥只能在打印机自带的屏幕上操作(Bambu 的限制)',
       stoppingDrying: '正在停止干燥...',
       stoppingDrying: '正在停止干燥...',
       rotateTray: '干燥时旋转料盘',
       rotateTray: '干燥时旋转料盘',
+      ams3hDryerSlots: 'AMS 3H 仅烘干槽位 {{a}} 和 {{b}}',
       rotateUnavailableReason: '不可用 — 此 AMS 中有插槽已装入打印头。料盘被送料管固定,无法旋转。请先回退耗材。',
       rotateUnavailableReason: '不可用 — 此 AMS 中有插槽已装入打印头。料盘被送料管固定,无法旋转。请先回退耗材。',
       startMode: '开始时间',
       startMode: '开始时间',
       modeNow: '立即',
       modeNow: '立即',

+ 4 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -421,6 +421,7 @@ export default {
     // Printer card
     // Printer card
     readyToPrint: '準備列印',
     readyToPrint: '準備列印',
     external: '外部',
     external: '外部',
+    externalInputsEmpty: '{{count}} 個外部輸入皆為空',
     ext: '外接',
     ext: '外接',
     extL: '外接左',
     extL: '外接左',
     extR: '外接右',
     extR: '外接右',
@@ -497,6 +498,7 @@ export default {
     activeNozzle: '目前:{{nozzle}} 噴嘴',
     activeNozzle: '目前:{{nozzle}} 噴嘴',
     amsSwitchInletTooltip: '耗材變軌器 IN-{{inlet}}({{side}})— 可向兩個噴嘴供料',
     amsSwitchInletTooltip: '耗材變軌器 IN-{{inlet}}({{side}})— 可向兩個噴嘴供料',
     nozzleRack: '噴嘴架',
     nozzleRack: '噴嘴架',
+    nozzleRackMounted: '在工具頭上',
     nozzleDocked: '已停靠',
     nozzleDocked: '已停靠',
     nozzleMounted: '已安裝',
     nozzleMounted: '已安裝',
     nozzleActive: '使用中',
     nozzleActive: '使用中',
@@ -570,6 +572,7 @@ export default {
       rfidRereadInitiated: '已發起 RFID 重新讀取',
       rfidRereadInitiated: '已發起 RFID 重新讀取',
       loadInitiated: '載入耗材中…',
       loadInitiated: '載入耗材中…',
       unloadInitiated: '卸載耗材中…',
       unloadInitiated: '卸載耗材中…',
+      unloadAms3h: '正在退出槽位 {{n}} 的線材。印表機提示時,請手動將線材從 AMS 3H 拉出。',
       failedToLoad: '載入耗材失敗',
       failedToLoad: '載入耗材失敗',
       failedToUnload: '卸載耗材失敗',
       failedToUnload: '卸載耗材失敗',
     },
     },
@@ -776,6 +779,7 @@ export default {
       screenOnly: '此印表機的 AMS 乾燥只能在印表機本身的螢幕上操作(Bambu 的限制)',
       screenOnly: '此印表機的 AMS 乾燥只能在印表機本身的螢幕上操作(Bambu 的限制)',
       stoppingDrying: '正在停止乾燥...',
       stoppingDrying: '正在停止乾燥...',
       rotateTray: '乾燥時旋轉料盤',
       rotateTray: '乾燥時旋轉料盤',
+      ams3hDryerSlots: 'AMS 3H 僅烘乾槽位 {{a}} 和 {{b}}',
       rotateUnavailableReason: '無法使用 — 此 AMS 中有插槽已裝入列印頭。料盤被進料管固定,無法旋轉。請先退回耗材。',
       rotateUnavailableReason: '無法使用 — 此 AMS 中有插槽已裝入列印頭。料盤被進料管固定,無法旋轉。請先退回耗材。',
       startMode: '開始時間',
       startMode: '開始時間',
       modeNow: '立即',
       modeNow: '立即',

+ 6 - 7
frontend/src/pages/InventoryPage.tsx

@@ -32,7 +32,7 @@ import { useColorCatalogVersion } from '../hooks/useColorCatalogVersion';
 import { getCurrencySymbol } from '../utils/currency';
 import { getCurrencySymbol } from '../utils/currency';
 import { shownCostPerKg } from '../utils/spoolCost';
 import { shownCostPerKg } from '../utils/spoolCost';
 import { formatDateInput, parseUTCDate, type DateFormat } from '../utils/date';
 import { formatDateInput, parseUTCDate, type DateFormat } from '../utils/date';
-import { formatSlotLabel } from '../utils/amsHelpers';
+import { formatAssignmentSlotLabel } from '../utils/amsHelpers';
 import { filterSpoolsByQuery } from '../utils/inventorySearch';
 import { filterSpoolsByQuery } from '../utils/inventorySearch';
 import {
 import {
   inventoryLocationsQueryKey,
   inventoryLocationsQueryKey,
@@ -214,6 +214,7 @@ type LocationDisplay = {
   ams_id: number;
   ams_id: number;
   tray_id: number;
   tray_id: number;
   ams_label: string | null;
   ams_label: string | null;
+  printer_model?: string | null;  // for formatAssignmentSlotLabel
 };
 };
 
 
 type CellCtx = {
 type CellCtx = {
@@ -327,9 +328,7 @@ const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
     const assignment = assignmentMap[spool.id];
     const assignment = assignmentMap[spool.id];
     if (!assignment) return <span className="text-sm text-bambu-gray">-</span>;
     if (!assignment) return <span className="text-sm text-bambu-gray">-</span>;
     const printerLabel = assignment.printer_name || `Printer ${assignment.printer_id}`;
     const printerLabel = assignment.printer_name || `Printer ${assignment.printer_id}`;
-    const isExternal = assignment.ams_id === 254 || assignment.ams_id === 255;
-    const isHt = !isExternal && assignment.ams_id >= 128;
-    const slotLabel = formatSlotLabel(assignment.ams_id, assignment.tray_id, isHt, isExternal);
+    const slotLabel = formatAssignmentSlotLabel(assignment);
     return (
     return (
       <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-400">
       <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-400">
         {printerLabel} {slotLabel}{assignment.ams_label ? ` (${assignment.ams_label})` : ''}
         {printerLabel} {slotLabel}{assignment.ams_label ? ` (${assignment.ams_label})` : ''}
@@ -576,10 +575,8 @@ const columnSortValues: Record<
   location: (s, am) => {
   location: (s, am) => {
     const a = am[s.id];
     const a = am[s.id];
     if (!a) return '';
     if (!a) return '';
-    const isExt = a.ams_id === 254 || a.ams_id === 255;
-    const isHt = !isExt && a.ams_id >= 128;
     const label = a.ams_label ? ` (${a.ams_label})` : '';
     const label = a.ams_label ? ` (${a.ams_label})` : '';
-    return `${a.printer_name || ''} ${formatSlotLabel(a.ams_id, a.tray_id, isHt, isExt)}${label}`;
+    return `${a.printer_name || ''} ${formatAssignmentSlotLabel(a)}${label}`;
   },
   },
   storage_location: (s) => (s.storage_location || '').toLowerCase(),
   storage_location: (s) => (s.storage_location || '').toLowerCase(),
   label_weight: (s) => s.label_weight,
   label_weight: (s) => s.label_weight,
@@ -1235,6 +1232,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
         ams_id: a.ams_id,
         ams_id: a.ams_id,
         tray_id: a.tray_id,
         tray_id: a.tray_id,
         ams_label: a.ams_label ?? null,
         ams_label: a.ams_label ?? null,
+        printer_model: a.printer_model ?? null,
       };
       };
     }
     }
     for (const a of spoolmanSlotAssignments) {
     for (const a of spoolmanSlotAssignments) {
@@ -1255,6 +1253,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
           ams_id: a.ams_id,
           ams_id: a.ams_id,
           tray_id: a.tray_id,
           tray_id: a.tray_id,
           ams_label: a.ams_label ?? null,
           ams_label: a.ams_label ?? null,
+          printer_model: a.printer_model ?? null,
         };
         };
       }
       }
     }
     }

+ 163 - 67
frontend/src/pages/PrintersPage.tsx

@@ -166,7 +166,7 @@ import { ConfirmOutcomeDialog } from '../components/ConfirmOutcomeDialog';
 import { Link as RouterLink, useNavigate } from 'react-router-dom';
 import { Link as RouterLink, useNavigate } from 'react-router-dom';
 import { api, discoveryApi, firmwareApi, withMediaToken, ApiError } from '../api/client';
 import { api, discoveryApi, firmwareApi, withMediaToken, ApiError } from '../api/client';
 import { formatDateOnly, formatDateTime, formatETA, formatDuration, formatDurationFromHours, parseUTCDate } from '../utils/date';
 import { formatDateOnly, formatDateTime, formatETA, formatDuration, formatDurationFromHours, parseUTCDate } from '../utils/date';
-import type { Printer, PrinterCreate, PrinterStatus, AMSUnit, DiscoveredPrinter, FirmwareUpdateInfo, FirmwareUploadStatus, LinkedSpoolInfo, SpoolAssignment, HMSError, InventorySpool, SmartPlug, PrinterDiagnosticResult } from '../api/client';
+import type { Printer, PrinterCreate, PrinterStatus, AMSUnit, AMSTray, DiscoveredPrinter, FirmwareUpdateInfo, FirmwareUploadStatus, LinkedSpoolInfo, SpoolAssignment, HMSError, InventorySpool, SmartPlug, PrinterDiagnosticResult } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { ConfirmModal } from '../components/ConfirmModal';
@@ -199,7 +199,8 @@ import { PrintModal } from '../components/PrintModal';
 import { PrinterInfoModal } from '../components/PrinterInfoModal';
 import { PrinterInfoModal } from '../components/PrinterInfoModal';
 import { FeedDirectionModal } from '../components/FeedDirectionModal';
 import { FeedDirectionModal } from '../components/FeedDirectionModal';
 import { plateCheckFailureText } from '../utils/plateCheck';
 import { plateCheckFailureText } from '../utils/plateCheck';
-import { amsHasLoadedTray, getAmsLabel, getEmptySlotKind, getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, installedNozzleDiameters, isAmsHtUnit, isBambuLabSpool, trayForSlot, resolveSlotNozzleDiameter, resolveSlotExtruder, formatSlotLabel, slotPresetDescribesTray, FTS_INLET_SIDE } from '../utils/amsHelpers';
+import { ams3hDryerGridColumns, ams3hSlotGridPlacement, amsUnitSlotCount, isAms3hUnit, rackExternalLabel, amsHasLoadedTray, getAmsLabel, getEmptySlotKind, getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, installedNozzleDiameters, isAmsHtUnit, isBambuLabSpool, trayForSlot, resolveSlotNozzleDiameter, resolveSlotExtruder, formatSlotLabel, slotPresetDescribesTray, FTS_INLET_SIDE } from '../utils/amsHelpers';
+import { RACK_SLOT_COUNT, externalHotend, externalSlotIndex, isExternalTray, layoutAmsType, locateTray, trayLayoutOf } from '../utils/trayIds';
 import { MAX_CHAMBER_TEMP_C, getPrinterImage, getWifiStrength, filterCompatibleQueueItems, isPrinterCurrentlyDispatchable, IDLE_PRINTER_STATES } from '../utils/printer';
 import { MAX_CHAMBER_TEMP_C, getPrinterImage, getWifiStrength, filterCompatibleQueueItems, isPrinterCurrentlyDispatchable, IDLE_PRINTER_STATES } from '../utils/printer';
 import { FilamentSlotCircle } from '../components/FilamentSlotCircle';
 import { FilamentSlotCircle } from '../components/FilamentSlotCircle';
 import { Collapsible } from '../components/Collapsible';
 import { Collapsible } from '../components/Collapsible';
@@ -725,8 +726,11 @@ function DualNozzleHoverCard({ leftSlot, rightSlot, activeNozzle, filamentInfo,
   );
   );
 }
 }
 
 
-// H2C Nozzle Rack Card — compact single row showing 6-position tool-changer dock
-function NozzleRackCard({ slots, filamentInfo }: { slots: import('../api/client').NozzleRackSlot[]; filamentInfo?: Record<string, { name: string; k: number | null }> }) {
+// H2C Nozzle Rack Card — compact single row showing 6-position tool-changer dock.
+// A rack printer (H3C) has one hotend per rack AMS slot, rack ids 16-24. The
+// one on the toolhead reports id 0 with `addr_id` = its rack position, so it
+// is drawn there, ringed.
+function NozzleRackCard({ slots, filamentInfo, rackPrinter = false }: { slots: import('../api/client').NozzleRackSlot[]; filamentInfo?: Record<string, { name: string; k: number | null }>; rackPrinter?: boolean }) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   // Rack nozzles only (IDs >= 2) — excludes L/R hotend nozzles (IDs 0, 1).
   // Rack nozzles only (IDs >= 2) — excludes L/R hotend nozzles (IDs 0, 1).
   // H2C rack slot IDs are fixed at 16..21. When a nozzle is picked up into the
   // H2C rack slot IDs are fixed at 16..21. When a nozzle is picked up into the
@@ -734,11 +738,13 @@ function NozzleRackCard({ slots, filamentInfo }: { slots: import('../api/client'
   // base — computing it from min(present IDs) shifts everything left when slot 16
   // base — computing it from min(present IDs) shifts everything left when slot 16
   // is the one currently mounted (#943).
   // is the one currently mounted (#943).
   const rackNozzles = slots.filter(s => s.id >= 2);
   const rackNozzles = slots.filter(s => s.id >= 2);
-  const RACK_SIZE = 6;
+  const RACK_SIZE = rackPrinter ? RACK_SLOT_COUNT : 6;
   const RACK_BASE_ID = 16;
   const RACK_BASE_ID = 16;
+  const mountedAt = (i: number) =>
+    rackPrinter ? slots.find(s => s.id === 0 && s.addr_id === i) : undefined;
   const rackSlots: (import('../api/client').NozzleRackSlot)[] = Array.from(
   const rackSlots: (import('../api/client').NozzleRackSlot)[] = Array.from(
     { length: RACK_SIZE },
     { length: RACK_SIZE },
-    (_, i) => rackNozzles.find(s => s.id === RACK_BASE_ID + i) ?? {
+    (_, i) => rackNozzles.find(s => s.id === RACK_BASE_ID + i) ?? mountedAt(i) ?? {
       id: -(i + 1), nozzle_type: '', nozzle_diameter: '', wear: null, stat: null,
       id: -(i + 1), nozzle_type: '', nozzle_diameter: '', wear: null, stat: null,
       max_temp: 0, serial_number: '', filament_color: '', filament_id: '', filament_type: '',
       max_temp: 0, serial_number: '', filament_color: '', filament_id: '', filament_type: '',
     },
     },
@@ -753,9 +759,10 @@ function NozzleRackCard({ slots, filamentInfo }: { slots: import('../api/client'
     // instead of overflowing.
     // instead of overflowing.
     <div className="text-center px-2.5 py-1.5 bg-bambu-dark rounded-lg flex-[0_1_auto] flex flex-col justify-center">
     <div className="text-center px-2.5 py-1.5 bg-bambu-dark rounded-lg flex-[0_1_auto] flex flex-col justify-center">
       <p className="text-[length:var(--pc-t9,9px)] text-bambu-gray mb-1">{t('printers.nozzleRack')}</p>
       <p className="text-[length:var(--pc-t9,9px)] text-bambu-gray mb-1">{t('printers.nozzleRack')}</p>
-      <div className="flex gap-[3px] justify-center">
+      <div className={`flex gap-[3px] justify-center${rackPrinter ? ' flex-wrap' : ''}`}>
         {rackSlots.map((slot, i) => {
         {rackSlots.map((slot, i) => {
           const isEmpty = !slot.nozzle_diameter && !slot.nozzle_type;
           const isEmpty = !slot.nozzle_diameter && !slot.nozzle_type;
+          const isMounted = rackPrinter && slot.id === 0;
           const filamentBg = !isEmpty ? parseFilamentColor(slot.filament_color) : null;
           const filamentBg = !isEmpty ? parseFilamentColor(slot.filament_color) : null;
           const lightBg = filamentBg ? isLightColor(slot.filament_color) : false;
           const lightBg = filamentBg ? isLightColor(slot.filament_color) : false;
 
 
@@ -763,11 +770,13 @@ function NozzleRackCard({ slots, filamentInfo }: { slots: import('../api/client'
             <NozzleSlotHoverCard key={slot.id >= 0 ? slot.id : `empty-${i}`} slot={slot} index={i} filamentName={slot.filament_id ? filamentInfo?.[slot.filament_id]?.name : undefined}>
             <NozzleSlotHoverCard key={slot.id >= 0 ? slot.id : `empty-${i}`} slot={slot} index={i} filamentName={slot.filament_id ? filamentInfo?.[slot.filament_id]?.name : undefined}>
               <div className="flex flex-col items-center gap-0.5">
               <div className="flex flex-col items-center gap-0.5">
                 <div
                 <div
+                  data-rack-mounted={isMounted ? 'true' : undefined}
+                  title={isMounted ? t('printers.nozzleRackMounted') : undefined}
                   className={`w-[var(--pc-i7,28px)] h-[var(--pc-i7,28px)] rounded flex items-center justify-center cursor-default transition-colors border-b-2 ${
                   className={`w-[var(--pc-i7,28px)] h-[var(--pc-i7,28px)] rounded flex items-center justify-center cursor-default transition-colors border-b-2 ${
                     isEmpty
                     isEmpty
                       ? 'bg-bambu-dark-tertiary/20 border-bambu-dark-tertiary/20'
                       ? 'bg-bambu-dark-tertiary/20 border-bambu-dark-tertiary/20'
                       : 'bg-bambu-dark-tertiary/40 border-bambu-dark-tertiary/40'
                       : 'bg-bambu-dark-tertiary/40 border-bambu-dark-tertiary/40'
-                  }`}
+                  }${isMounted ? ' ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}
                   style={filamentBg ? { backgroundColor: filamentBg } : undefined}
                   style={filamentBg ? { backgroundColor: filamentBg } : undefined}
                 >
                 >
                   <span
                   <span
@@ -783,7 +792,7 @@ function NozzleRackCard({ slots, filamentInfo }: { slots: import('../api/client'
                     than inside it: the chip is barely wider than its own
                     than inside it: the chip is barely wider than its own
                     diameter text and already carries that over a
                     diameter text and already carries that over a
                     filament-coloured background. */}
                     filament-coloured background. */}
-                <span className="text-[length:var(--pc-t8,8px)] leading-none tabular-nums text-bambu-gray/70">
+                <span className={`text-[length:var(--pc-t8,8px)] leading-none tabular-nums ${isMounted ? 'text-bambu-green font-semibold' : 'text-bambu-gray/70'}`}>
                   {i + 1}
                   {i + 1}
                 </span>
                 </span>
               </div>
               </div>
@@ -1735,6 +1744,7 @@ const MODELS_WITH_CHAMBER_FAN: ReadonlySet<string> = new Set([
   'H2D Pro',
   'H2D Pro',
   'H2C',
   'H2C',
   'H2S',
   'H2S',
+  'H3C',
 ]);
 ]);
 
 
 // On the P2S/X2D, the enclosure fan (big_fan2 / airduct part id 3) is a
 // On the P2S/X2D, the enclosure fan (big_fan2 / airduct part id 3) is a
@@ -2190,6 +2200,8 @@ function PrinterCard({
   // AMS drying popover state: which AMS unit has the popover open
   // AMS drying popover state: which AMS unit has the popover open
   const [dryingPopoverAmsId, setDryingPopoverAmsId] = useState<number | null>(null);
   const [dryingPopoverAmsId, setDryingPopoverAmsId] = useState<number | null>(null);
   const [dryingPopoverModuleType, setDryingPopoverModuleType] = useState<string>('n3f');
   const [dryingPopoverModuleType, setDryingPopoverModuleType] = useState<string>('n3f');
+  // The AMS 3H dryer's two slots (tray ids 0-8), named in the popover.
+  const [dryingPopoverDrySlots, setDryingPopoverDrySlots] = useState<number[] | null>(null);
   const [dryingFilament, setDryingFilament] = useState('PLA');
   const [dryingFilament, setDryingFilament] = useState('PLA');
   const [dryingTemp, setDryingTemp] = useState(50);
   const [dryingTemp, setDryingTemp] = useState(50);
   const [dryingDuration, setDryingDuration] = useState(4);
   const [dryingDuration, setDryingDuration] = useState(4);
@@ -2204,12 +2216,15 @@ function PrinterCard({
   // Reset every field the popover owns, including the start mode. Leaving the
   // Reset every field the popover owns, including the start mode. Leaving the
   // mode alone let an "At time" timestamp from an earlier schedule persist into
   // mode alone let an "At time" timestamp from an earlier schedule persist into
   // the next open, by then in the past, and the POST rejected it.
   // the next open, by then in the past, and the POST rejected it.
-  const openDryingPopover = useCallback((ams: AMSUnit, trigger: HTMLElement) => {
-    const firstTray = ams.tray.find(t => t.tray_type);
+  // `dryer`: an AMS 3H dries only its dryer's two slots, so the preset follows
+  // the spool in one of those, not slot 1.
+  const openDryingPopover = useCallback((ams: AMSUnit, trigger: HTMLElement, dryer?: { slots: number[]; trays: AMSTray[] }) => {
+    const firstTray = (dryer ? dryer.trays : ams.tray).find(t => t.tray_type);
     const filType = resolveDryingPresetKey(firstTray?.tray_type, dryingPresets);
     const filType = resolveDryingPresetKey(firstTray?.tray_type, dryingPresets);
     // Only reachable if a custom preset set dropped PLA itself.
     // Only reachable if a custom preset set dropped PLA itself.
     const preset = dryingPresets[filType] ?? DRYING_PRESETS['PLA'];
     const preset = dryingPresets[filType] ?? DRYING_PRESETS['PLA'];
-    const moduleType = ams.module_type as 'n3f' | 'n3s';
+    // The H3C's AMS 3H (n10a) dries at 45-85 °C like the AMS-HT, so it takes the HT presets and range.
+    const moduleType = (isAms3hUnit(ams) ? 'n3s' : ams.module_type) as 'n3f' | 'n3s';
     setDryingFilament(filType);
     setDryingFilament(filType);
     setDryingTemp(preset[moduleType] || preset.n3f);
     setDryingTemp(preset[moduleType] || preset.n3f);
     setDryingDuration(moduleType === 'n3s' ? preset.n3s_hours : preset.n3f_hours);
     setDryingDuration(moduleType === 'n3s' ? preset.n3s_hours : preset.n3f_hours);
@@ -2217,7 +2232,8 @@ function PrinterCard({
     setDryingStartMode('now');
     setDryingStartMode('now');
     setDryingDelayMinutes(120);
     setDryingDelayMinutes(120);
     setDryingStartAt('');
     setDryingStartAt('');
-    setDryingPopoverModuleType(ams.module_type);
+    setDryingPopoverModuleType(moduleType);
+    setDryingPopoverDrySlots(dryer ? dryer.slots : null);
     setDryingPopoverAmsId(ams.id);
     setDryingPopoverAmsId(ams.id);
     setDryingPopoverAnchor(trigger);
     setDryingPopoverAnchor(trigger);
   }, [dryingPresets]);
   }, [dryingPresets]);
@@ -2551,17 +2567,20 @@ function PrinterCard({
   // so these are often different — the graphic highlights the expected one.
   // so these are often different — the graphic highlights the expected one.
   const expectedTray = status?.expected_tray ?? null;
   const expectedTray = status?.expected_tray ?? null;
   const previousTray = status?.previous_tray ?? null;
   const previousTray = status?.previous_tray ?? null;
+  const trayLayout = useMemo(() => trayLayoutOf(status?.ams, status?.vt_tray), [status?.ams, status?.vt_tray]);
   // Pre-format the runout slot labels (honoring user AMS friendly names) for the
   // Pre-format the runout slot labels (honoring user AMS friendly names) for the
   // HMS modal (#2587). null when the slot can't be placed → honest fallback copy.
   // HMS modal (#2587). null when the slot can't be placed → honest fallback copy.
   const formatRunoutSlotLabel = (globalId: number | null): string | null => {
   const formatRunoutSlotLabel = (globalId: number | null): string | null => {
     if (globalId === null) return null;
     if (globalId === null) return null;
+    if (trayLayout.rack && isExternalTray(globalId, true)) return rackExternalLabel(globalId);
     if (globalId === 254) return t('printers.expectedSlot.external');
     if (globalId === 254) return t('printers.expectedSlot.external');
-    if (globalId >= 128 && globalId <= 135) {
-      return amsLabels?.[globalId] || getAmsLabel(globalId);
+    const located = locateTray(trayLayout, globalId);
+    if (!located) return null;
+    const [amsId, slot] = located;
+    if (isAmsHtUnit(amsId)) {
+      return amsLabels?.[amsId] || getAmsLabel(amsId);
     }
     }
-    const amsId = Math.floor(globalId / 4);
-    const slot = globalId % 4;
-    const amsName = amsLabels?.[amsId] || getAmsLabel(amsId);
+    const amsName = amsLabels?.[amsId] || getAmsLabel(amsId, layoutAmsType(trayLayout, amsId));
     return t('printers.expectedSlot.label', { ams: amsName, slot: slot + 1 });
     return t('printers.expectedSlot.label', { ams: amsName, slot: slot + 1 });
   };
   };
   const runoutGuidance = status?.state === 'PAUSE'
   const runoutGuidance = status?.state === 'PAUSE'
@@ -3231,7 +3250,14 @@ function PrinterCard({
 
 
   const unloadAmsMutation = useMutation({
   const unloadAmsMutation = useMutation({
     mutationFn: ({ trayId }: { trayId?: number }) => api.unloadAms(printer.id, trayId),
     mutationFn: ({ trayId }: { trayId?: number }) => api.unloadAms(printer.id, trayId),
-    onSuccess: (data) => {
+    onSuccess: (data, { trayId }) => {
+      // H3C: the AMS 3H can't rewind. The printer cuts the filament and pulls it
+      // out of the hotend, then waits for it to be pulled back by hand.
+      const ams3hSlot = trayId !== undefined ? locateTray(trayLayout, trayId) : null;
+      if (ams3hSlot && isAms3hUnit(status?.ams?.find((unit) => unit.id === ams3hSlot[0]))) {
+        showToast(t('printers.toast.unloadAms3h', { n: ams3hSlot[1] + 1 }), 'warning');
+        return;
+      }
       showToast(data.message || t('printers.toast.unloadInitiated'));
       showToast(data.message || t('printers.toast.unloadInitiated'));
     },
     },
     onError: (error: Error) => {
     onError: (error: Error) => {
@@ -4219,7 +4245,7 @@ function PrinterCard({
 
 
               {/* Enclosure Door Badge — models with an actual door sensor.
               {/* Enclosure Door Badge — models with an actual door sensor.
                   P1S has an enclosure door but no sensor; P1P has no enclosure at all. */}
                   P1S has an enclosure door but no sensor; P1P has no enclosure at all. */}
-              {status?.connected && ['X1C', 'X1', 'X1E', 'X2D', 'P2S', 'H2D', 'H2D Pro', 'H2C', 'H2S'].includes(printer.model ?? '') && (
+              {status?.connected && ['X1C', 'X1', 'X1E', 'X2D', 'P2S', 'H2D', 'H2D Pro', 'H2C', 'H2S', 'H3C'].includes(printer.model ?? '') && (
                 <span
                 <span
                   className={`flex items-center px-2 py-1 rounded-full text-xs ${
                   className={`flex items-center px-2 py-1 rounded-full text-xs ${
                     status.door_open
                     status.door_open
@@ -4890,7 +4916,11 @@ function PrinterCard({
                     )}
                     )}
                     {/* H2C nozzle rack (tool-changer dock) — only show when rack nozzles exist (IDs >= 2) */}
                     {/* H2C nozzle rack (tool-changer dock) — only show when rack nozzles exist (IDs >= 2) */}
                     {status.nozzle_rack && status.nozzle_rack.some(s => s.id >= 2) && (
                     {status.nozzle_rack && status.nozzle_rack.some(s => s.id >= 2) && (
-                      <NozzleRackCard slots={status.nozzle_rack} filamentInfo={filamentInfo} />
+                      <NozzleRackCard
+                        slots={status.nozzle_rack}
+                        filamentInfo={filamentInfo}
+                        rackPrinter={trayLayout.rack}
+                      />
                     )}
                     )}
                   </div>
                   </div>
                   <div className="mt-2 flex items-center gap-1.5">
                   <div className="mt-2 flex items-center gap-1.5">
@@ -4968,8 +4998,8 @@ function PrinterCard({
                         <ChamberLight on={status.chamber_light ?? false} className="w-[var(--pc-i4,1rem)] h-[var(--pc-i4,1rem)]" />
                         <ChamberLight on={status.chamber_light ?? false} className="w-[var(--pc-i4,1rem)] h-[var(--pc-i4,1rem)]" />
                       </button>
                       </button>
 
 
-                      {/* Airduct Mode (P2S / X2D / H2*, an H2D Pro stored as "H2D Pro", #3335) */}
-                      {(['P2S', 'X2D', 'H2D', 'H2D Pro', 'H2C', 'H2S'].includes(printer.model ?? '')) && (() => {
+                      {/* Airduct Mode (P2S / X2D / H2* / H3C, an H2D Pro stored as "H2D Pro", #3335) */}
+                      {(['P2S', 'X2D', 'H2D', 'H2D Pro', 'H2C', 'H2S', 'H3C'].includes(printer.model ?? '')) && (() => {
                         const isHeating = status.airduct_mode === 1;
                         const isHeating = status.airduct_mode === 1;
                         const Icon = isHeating ? Flame : Snowflake;
                         const Icon = isHeating ? Flame : Snowflake;
                         const color = isHeating ? 'text-orange-600 dark:text-orange-400' : 'text-sky-600 dark:text-sky-400';
                         const color = isHeating ? 'text-orange-600 dark:text-orange-400' : 'text-sky-600 dark:text-sky-400';
@@ -5369,6 +5399,9 @@ function PrinterCard({
               const slotGridStyle = (columns: number): React.CSSProperties => ({
               const slotGridStyle = (columns: number): React.CSSProperties => ({
                 gridTemplateColumns: `repeat(${columns}, minmax(${slotMinWidth}, 1fr))`,
                 gridTemplateColumns: `repeat(${columns}, minmax(${slotMinWidth}, 1fr))`,
               });
               });
+              const ams3hSlotGridStyle: React.CSSProperties = {
+                gridTemplateColumns: `repeat(10, minmax(calc(${slotMinWidth} / 2), 1fr))`,
+              };
               // #1762 (comment 2): while a print is running/paused, overlay a small
               // #1762 (comment 2): while a print is running/paused, overlay a small
               // "P1 / P2 / P3" pill on each slot referenced by the active print's
               // "P1 / P2 / P3" pill on each slot referenced by the active print's
               // mapping. Catches the reporter's scenario — "any X1C" queue job
               // mapping. Catches the reporter's scenario — "any X1C" queue job
@@ -5378,9 +5411,9 @@ function PrinterCard({
               const activeMapping: number[] = isPrintingForMapping && Array.isArray(status.ams_mapping)
               const activeMapping: number[] = isPrintingForMapping && Array.isArray(status.ams_mapping)
                 ? status.ams_mapping
                 ? status.ams_mapping
                 : [];
                 : [];
-              const getAmsCardStyle = (slotCount: number): React.CSSProperties => {
+              const getAmsCardStyle = (slotCount: number, gaps?: number): React.CSSProperties => {
                 const boundedSlotCount = Math.max(1, slotCount);
                 const boundedSlotCount = Math.max(1, slotCount);
-                const gapCount = Math.max(0, boundedSlotCount - 1);
+                const gapCount = gaps ?? Math.max(0, boundedSlotCount - 1);
                 const minWidth = `calc(${boundedSlotCount} * ${slotMinWidth} + ${gapCount} * 0.25rem + 1rem)`;
                 const minWidth = `calc(${boundedSlotCount} * ${slotMinWidth} + ${gapCount} * 0.25rem + 1rem)`;
                 return {
                 return {
                   flex: `1 1 ${minWidth}`,
                   flex: `1 1 ${minWidth}`,
@@ -5419,9 +5452,24 @@ function PrinterCard({
                     {/* Regular AMS units */}
                     {/* Regular AMS units */}
                     {regularAms.map((ams) => {
                     {regularAms.map((ams) => {
                       const sideBadge = amsSideBadge(ams.id, amsExtruderMap, amsSwitchInlet, ftsInstalled);
                       const sideBadge = amsSideBadge(ams.id, amsExtruderMap, amsSwitchInlet, ftsInstalled);
+                      const cardAms3h = isAms3hUnit(ams);
+                      const cardLabel = getAmsLabel(ams.id, ams.ams_type);
+                      const slotIndexes = Array.from({ length: amsUnitSlotCount(ams) }, (_, slotIdx) => slotIdx);
+                      // The AMS 3H dryer box around its two back-row slots, with the
+                      // dryer's own humidity and temperature.
+                      const ams3hDrySlots = cardAms3h ? (ams.dryer_slots ?? []) : [];
+                      const ams3hDryer = cardAms3h ? ams3hDryerGridColumns(ams3hDrySlots) : null;
+                      const ams3hDryerActive = ams.dry_time > 0 || (ams.dry_status >= 1 && ams.dry_status <= 4);
+                      const ams3hDryerTitle = ams3hDrySlots.length === 2
+                        ? t('printers.drying.ams3hDryerSlots', { a: ams3hDrySlots[0] + 1, b: ams3hDrySlots[1] + 1 })
+                        : undefined;
+                      const ams3hDryerTrays = ams3hDrySlots
+                        .map((slotIdx) => trayForSlot(ams.tray, slotIdx))
+                        .filter((tray): tray is AMSTray => tray !== undefined);
 
 
                       return (
                       return (
-                        <div key={ams.id} style={getAmsCardStyle(4)} className="min-w-0 p-2 bg-bambu-dark rounded-[10px] space-y-1">
+                        // The AMS 3H card is ten half-slot columns wide: five slots, nine gaps.
+                        <div key={ams.id} style={cardAms3h ? getAmsCardStyle(5, 9) : getAmsCardStyle(4)} className="min-w-0 p-2 bg-bambu-dark rounded-[10px] space-y-1">
                             {/* Header: Label + Stats (no icon) */}
                             {/* Header: Label + Stats (no icon) */}
                             <div className="flex w-full min-h-7 items-center justify-between gap-2 rounded-lg bg-bambu-dark-secondary px-2 py-1">
                             <div className="flex w-full min-h-7 items-center justify-between gap-2 rounded-lg bg-bambu-dark-secondary px-2 py-1">
                               <div className="flex min-w-0 flex-1 items-center gap-1.5">
                               <div className="flex min-w-0 flex-1 items-center gap-1.5">
@@ -5429,13 +5477,13 @@ function PrinterCard({
                                 <AmsNameHoverCard
                                 <AmsNameHoverCard
                                   ams={ams}
                                   ams={ams}
                                   printerId={printer.id}
                                   printerId={printer.id}
-                                  label={getAmsLabel(ams.id)}
+                                  label={cardLabel}
                                   amsLabels={amsLabels}
                                   amsLabels={amsLabels}
                                   canEdit={hasPermission('printers:update')}
                                   canEdit={hasPermission('printers:update')}
                                   onSaved={refetchAmsLabels}
                                   onSaved={refetchAmsLabels}
                                 >
                                 >
                                   <span className="block truncate text-[length:var(--pc-t10,10px)] text-white font-medium cursor-default select-none">
                                   <span className="block truncate text-[length:var(--pc-t10,10px)] text-white font-medium cursor-default select-none">
-                                    {amsLabels?.[ams.id] || getAmsLabel(ams.id)}
+                                    {amsLabels?.[ams.id] || cardLabel}
                                   </span>
                                   </span>
                                 </AmsNameHoverCard>
                                 </AmsNameHoverCard>
                                 {sideBadge?.kind === 'inlet' ? (
                                 {sideBadge?.kind === 'inlet' ? (
@@ -5459,7 +5507,7 @@ function PrinterCard({
                                       fairThreshold={amsThresholds?.humidityFair}
                                       fairThreshold={amsThresholds?.humidityFair}
                                       onClick={() => setAmsHistoryModal({
                                       onClick={() => setAmsHistoryModal({
                                         amsId: ams.id,
                                         amsId: ams.id,
-                                        amsLabel: getAmsLabel(ams.id),
+                                        amsLabel: cardLabel,
                                         mode: 'humidity',
                                         mode: 'humidity',
                                       })}
                                       })}
                                       compact
                                       compact
@@ -5473,7 +5521,7 @@ function PrinterCard({
                                         fairThreshold={amsThresholds?.tempFair}
                                         fairThreshold={amsThresholds?.tempFair}
                                         onClick={() => setAmsHistoryModal({
                                         onClick={() => setAmsHistoryModal({
                                           amsId: ams.id,
                                           amsId: ams.id,
-                                          amsLabel: getAmsLabel(ams.id),
+                                          amsLabel: cardLabel,
                                           mode: 'temperature',
                                           mode: 'temperature',
                                         })}
                                         })}
                                         compact
                                         compact
@@ -5483,7 +5531,7 @@ function PrinterCard({
                                   {/* Drying button — only for AMS 2 Pro (n3f) and AMS-HT (n3s).
                                   {/* Drying button — only for AMS 2 Pro (n3f) and AMS-HT (n3s).
                                       Screen-only models (P1 series) keep the control but can't
                                       Screen-only models (P1 series) keep the control but can't
                                       be commanded: it stays disabled and says why (#2533). */}
                                       be commanded: it stays disabled and says why (#2533). */}
-                                  {(status.supports_drying || status.drying_screen_only) && (ams.module_type === 'n3f' || ams.module_type === 'n3s') && hasPermission('printers:control') && (
+                                  {(status.supports_drying || status.drying_screen_only) && (ams.module_type === 'n3f' || ams.module_type === 'n3s' || isAms3hUnit(ams)) && hasPermission('printers:control') && (
                                     <button
                                     <button
                                       disabled={status.drying_screen_only || !!(ams.dry_sf_reason?.length && ams.dry_time === 0)}
                                       disabled={status.drying_screen_only || !!(ams.dry_sf_reason?.length && ams.dry_time === 0)}
                                       onClick={(e) => {
                                       onClick={(e) => {
@@ -5492,7 +5540,11 @@ function PrinterCard({
                                         } else if (dryingPopoverAmsId === ams.id) {
                                         } else if (dryingPopoverAmsId === ams.id) {
                                           setDryingPopoverAmsId(null);
                                           setDryingPopoverAmsId(null);
                                         } else {
                                         } else {
-                                          openDryingPopover(ams, e.currentTarget as HTMLElement);
+                                          openDryingPopover(
+                                            ams,
+                                            e.currentTarget as HTMLElement,
+                                            ams3hDrySlots.length > 0 ? { slots: ams3hDrySlots, trays: ams3hDryerTrays } : undefined,
+                                          );
                                         }
                                         }
                                       }}
                                       }}
                                       className={`ml-1 flex items-center gap-0.5 px-1 py-0.5 rounded text-[length:var(--pc-t9,9px)] transition-colors ${
                                       className={`ml-1 flex items-center gap-0.5 px-1 py-0.5 rounded text-[length:var(--pc-t9,9px)] transition-colors ${
@@ -5575,17 +5627,37 @@ function PrinterCard({
                                 )}
                                 )}
                               </div>
                               </div>
                             )}
                             )}
-                            {/* Slots grid: 4 columns - always render 4 slots */}
-                            <div className="grid w-full gap-1" style={slotGridStyle(4)}>
-                              {[0, 1, 2, 3].map((slotIdx) => {
+                            {/* Slots grid: 4 columns - always render 4 slots (nine on the AMS 3H) */}
+                            <div className="grid w-full gap-1" style={cardAms3h ? ams3hSlotGridStyle : slotGridStyle(4)}>
+                              {ams3hDryer && (
+                                <div
+                                  aria-hidden="true"
+                                  data-testid="ams3h-dryer"
+                                  className={`rounded-lg pointer-events-none transition-colors ${ams3hDryerActive ? 'bg-amber-500/15 ring-1 ring-amber-500/40' : 'bg-bambu-dark-tertiary/60'}`}
+                                  style={{ gridRow: 1, gridColumn: `${ams3hDryer.start} / ${ams3hDryer.end}`, margin: '-0.125rem' }}
+                                />
+                              )}
+                              {ams3hDryer?.info != null && (
+                                <div
+                                  title={ams3hDryerTitle}
+                                  data-testid="ams3h-dryer-info"
+                                  className="flex flex-col items-center justify-center gap-0.5 text-[length:var(--pc-t9,9px)] leading-none text-bambu-gray"
+                                  style={{ gridRow: 1, gridColumn: `${ams3hDryer.info} / span 1` }}
+                                >
+                                  <Flame className={`w-[var(--pc-i3,0.75rem)] h-[var(--pc-i3,0.75rem)] ${ams3hDryerActive ? 'text-amber-500 dark:text-amber-400' : 'text-bambu-gray'}`} />
+                                  {ams.dryer_humidity != null && <span>{ams.dryer_humidity}%</span>}
+                                  {ams.dryer_temp != null && <span>{Math.round(ams.dryer_temp)}°</span>}
+                                </div>
+                              )}
+                              {slotIndexes.map((slotIdx) => {
                                 // Find tray data for this slot (may be undefined if data incomplete)
                                 // Find tray data for this slot (may be undefined if data incomplete)
                                 const tray = trayForSlot(ams.tray, slotIdx);
                                 const tray = trayForSlot(ams.tray, slotIdx);
                                 const hasFillLevel = tray?.tray_type && tray.remain >= 0;
                                 const hasFillLevel = tray?.tray_type && tray.remain >= 0;
                                 const isEmpty = !tray?.tray_type;
                                 const isEmpty = !tray?.tray_type;
                                 const emptyKind = getEmptySlotKind(tray);
                                 const emptyKind = getEmptySlotKind(tray);
                                 // Check if this is the currently loaded tray
                                 // Check if this is the currently loaded tray
-                                // Global tray ID = ams.id * 4 + slot index (for standard AMS)
-                                const globalTrayId = ams.id * 4 + slotIdx;
+                                // Global tray ID = ams.id * 4 + slot index (for standard AMS, utils/trayIds)
+                                const globalTrayId = getGlobalTrayId(ams.id, slotIdx, false, ams.ams_type);
                                 const isActive = effectiveTrayNow === globalTrayId;
                                 const isActive = effectiveTrayNow === globalTrayId;
                                 // Runout guidance (#2587): the slot the paused print now
                                 // Runout guidance (#2587): the slot the paused print now
                                 // expects filament in, and the slot that ran out.
                                 // expects filament in, and the slot that ran out.
@@ -5735,7 +5807,7 @@ function PrinterCard({
 
 
                                 // Wrapper with menu button, dropdown, and loading overlay (outside hover card)
                                 // Wrapper with menu button, dropdown, and loading overlay (outside hover card)
                                 return (
                                 return (
-                                  <div key={slotIdx} className="relative group w-full" style={filamentSlotStyle}>
+                                  <div key={slotIdx} className="relative group w-full" style={cardAms3h ? { ...filamentSlotStyle, ...ams3hSlotGridPlacement(slotIdx + 1) } : filamentSlotStyle}>
                                     {/* Loading overlay during RFID re-read */}
                                     {/* Loading overlay during RFID re-read */}
                                     {isRefreshing && (
                                     {isRefreshing && (
                                       <div className="absolute inset-0 bg-bambu-dark-tertiary/80 rounded flex items-center justify-center z-20">
                                       <div className="absolute inset-0 bg-bambu-dark-tertiary/80 rounded flex items-center justify-center z-20">
@@ -5749,7 +5821,7 @@ function PrinterCard({
                                         actions={renderAmsSlotActions({
                                         actions={renderAmsSlotActions({
                                           amsId: ams.id,
                                           amsId: ams.id,
                                           slotId: slotIdx,
                                           slotId: slotIdx,
-                                          loadTrayId: ams.id * 4 + slotIdx,
+                                          loadTrayId: globalTrayId,
                                           isRefreshing,
                                           isRefreshing,
                                         })}
                                         })}
                                         spoolman={{
                                         spoolman={{
@@ -5793,7 +5865,7 @@ function PrinterCard({
                                                   material: tray?.tray_type ?? undefined,
                                                   material: tray?.tray_type ?? undefined,
                                                   profile: filamentData.profile,
                                                   profile: filamentData.profile,
                                                   color: filamentData.colorHex || '',
                                                   color: filamentData.colorHex || '',
-                                                  location: `${getAmsLabel(ams.id)} Slot ${slotIdx + 1}`,
+                                                  location: `${cardLabel} Slot ${slotIdx + 1}`,
                                                 },
                                                 },
                                               }),
                                               }),
                                               onUnassignSpool: (spoolmanSpool && !isBambuLabSpool(tray)) ? () => onUnassignSpoolmanSpool?.(spoolmanSpool.id) : undefined,
                                               onUnassignSpool: (spoolmanSpool && !isBambuLabSpool(tray)) ? () => onUnassignSpoolmanSpool?.(spoolmanSpool.id) : undefined,
@@ -5826,7 +5898,7 @@ function PrinterCard({
                                                 material: tray?.tray_type ?? undefined,
                                                 material: tray?.tray_type ?? undefined,
                                                 profile: filamentData.profile,
                                                 profile: filamentData.profile,
                                                 color: filamentData.colorHex || '',
                                                 color: filamentData.colorHex || '',
-                                                location: `${getAmsLabel(ams.id)} Slot ${slotIdx + 1}`,
+                                                location: `${cardLabel} Slot ${slotIdx + 1}`,
                                               },
                                               },
                                             }),
                                             }),
                                             onUnassignSpool: (assignment && !isBambuLabSpool(tray)) ? () => onUnassignSpool?.(printer.id, ams.id, slotIdx) : undefined,
                                             onUnassignSpool: (assignment && !isBambuLabSpool(tray)) ? () => onUnassignSpool?.(printer.id, ams.id, slotIdx) : undefined,
@@ -5857,7 +5929,7 @@ function PrinterCard({
                                         actions={renderAmsSlotActions({
                                         actions={renderAmsSlotActions({
                                           amsId: ams.id,
                                           amsId: ams.id,
                                           slotId: slotIdx,
                                           slotId: slotIdx,
-                                          loadTrayId: ams.id * 4 + slotIdx,
+                                          loadTrayId: globalTrayId,
                                           isRefreshing,
                                           isRefreshing,
                                         })}
                                         })}
                                         configureSlot={{
                                         configureSlot={{
@@ -5877,7 +5949,7 @@ function PrinterCard({
                                             material: undefined,
                                             material: undefined,
                                             profile: '',
                                             profile: '',
                                             color: '',
                                             color: '',
-                                            location: `${getAmsLabel(ams.id)} Slot ${slotIdx + 1}`,
+                                            location: `${cardLabel} Slot ${slotIdx + 1}`,
                                             emptyKind,
                                             emptyKind,
                                           },
                                           },
                                         })}
                                         })}
@@ -6339,14 +6411,25 @@ function PrinterCard({
                           </div>
                           </div>
                         );
                         );
                       })}
                       })}
-                      {/* External spool(s) - grouped in one card like regular AMS */}
-                      {status.vt_tray.length > 0 && showExternalSpool && (
-                        <div style={getAmsCardStyle(status.vt_tray.length)} className="min-w-0 p-2 bg-bambu-dark rounded-[10px] space-y-1">
+                      {/* External spool(s) - grouped in one card like regular AMS. A
+                          rack printer's nine per-hotend inputs: only loaded ones get a
+                          tile, the empty ones one line. */}
+                      {status.vt_tray.length > 0 && showExternalSpool && (() => {
+                        const shownExternals = trayLayout.rack
+                          ? [...status.vt_tray].filter((vt) => vt.tray_type).sort((a, b) => (b.id ?? 254) - (a.id ?? 254))
+                          : [...status.vt_tray].sort((a, b) => (a.id ?? 254) - (b.id ?? 254));
+                        return (
+                        <div style={getAmsCardStyle(Math.max(1, shownExternals.length))} className="min-w-0 p-2 bg-bambu-dark rounded-[10px] space-y-1">
                           <div className="flex w-full min-h-7 items-center gap-1.5 rounded-lg bg-bambu-dark-secondary px-2 py-1">
                           <div className="flex w-full min-h-7 items-center gap-1.5 rounded-lg bg-bambu-dark-secondary px-2 py-1">
                             <span className="block min-w-0 flex-1 truncate text-[length:var(--pc-t10,10px)] text-white font-medium">{t('printers.external')}</span>
                             <span className="block min-w-0 flex-1 truncate text-[length:var(--pc-t10,10px)] text-white font-medium">{t('printers.external')}</span>
                           </div>
                           </div>
-                          <div className="grid w-full gap-1" style={slotGridStyle(status.vt_tray.length > 1 ? 2 : 1)}>
-                            {[...status.vt_tray].sort((a, b) => (a.id ?? 254) - (b.id ?? 254)).map((extTray) => {
+                          {trayLayout.rack && shownExternals.length === 0 && (
+                            <p className="px-1 text-[length:var(--pc-t9,9px)] text-bambu-gray" data-testid="rack-externals-empty">
+                              {t('printers.externalInputsEmpty', { count: status.vt_tray.length })}
+                            </p>
+                          )}
+                          <div className="grid w-full gap-1" style={slotGridStyle(shownExternals.length > 1 ? 2 : 1)}>
+                            {shownExternals.map((extTray) => {
                               const extTrayId = extTray.id ?? 254;
                               const extTrayId = extTray.id ?? 254;
                               // On dual-nozzle (H2C/H2D), tray_now=254 means "external spool"
                               // On dual-nozzle (H2C/H2D), tray_now=254 means "external spool"
                               // generically — use active_extruder to determine L vs R:
                               // generically — use active_extruder to determine L vs R:
@@ -6355,10 +6438,19 @@ function PrinterCard({
                                 ? (extTrayId === 254 && status.active_extruder === 1) ||
                                 ? (extTrayId === 254 && status.active_extruder === 1) ||
                                   (extTrayId === 255 && status.active_extruder === 0)
                                   (extTrayId === 255 && status.active_extruder === 0)
                                 : effectiveTrayNow === extTrayId;
                                 : effectiveTrayNow === extTrayId;
-                              const slotTrayId = extTrayId - 254; // 0 or 1
-                              const extLabel = isDualNozzle
-                                ? (extTrayId === 254 ? t('printers.extL') : t('printers.extR'))
-                                : '';
+                              const slotTrayId = externalSlotIndex(extTrayId); // 0 or 1
+                              const extLabel = trayLayout.rack
+                                ? rackExternalLabel(extTrayId)
+                                : isDualNozzle
+                                  ? (extTrayId === 254 ? t('printers.extL') : t('printers.extR'))
+                                  : '';
+                              // No load / unload for a rack printer's inputs (untested on the H3C).
+                              const extActions = trayLayout.rack ? undefined : renderAmsSlotActions({
+                                amsId: 255,
+                                slotId: slotTrayId,
+                                loadTrayId: extTrayId,
+                                includeRfid: false,
+                              });
                               const extCloudInfo = extTray.tray_info_idx ? filamentInfo?.[extTray.tray_info_idx] : null;
                               const extCloudInfo = extTray.tray_info_idx ? filamentInfo?.[extTray.tray_info_idx] : null;
                               const extSlotPreset = slotPresets?.[255 * 4 + slotTrayId];
                               const extSlotPreset = slotPresets?.[255 * 4 + slotTrayId];
                               // Only trusted while it still describes what the printer reports in the
                               // Only trusted while it still describes what the printer reports in the
@@ -6433,7 +6525,7 @@ function PrinterCard({
                                     isEmpty={isEmpty}
                                     isEmpty={isEmpty}
                                     emptyKind={emptyKind}
                                     emptyKind={emptyKind}
                                     pendingColor={pending?.colorHex}
                                     pendingColor={pending?.colorHex}
-                                    slotNumber={isDualNozzle ? (extTrayId === 254 ? 'L' : 'R') : slotTrayId + 1}
+                                    slotNumber={trayLayout.rack ? externalHotend(extTrayId) + 1 : isDualNozzle ? (extTrayId === 254 ? 'L' : 'R') : slotTrayId + 1}
                                   />
                                   />
                                   <div className={`text-[length:var(--pc-t9,9px)] font-bold truncate ${isEmpty && !pending ? 'text-white/40' : 'text-white'}`}>
                                   <div className={`text-[length:var(--pc-t9,9px)] font-bold truncate ${isEmpty && !pending ? 'text-white/40' : 'text-white'}`}>
                                     {extTray.tray_type || pending?.material || t('ams.slotEmpty')}
                                     {extTray.tray_type || pending?.material || t('ams.slotEmpty')}
@@ -6458,12 +6550,7 @@ function PrinterCard({
                                   {!isEmpty ? (
                                   {!isEmpty ? (
                                     <FilamentHoverCard
                                     <FilamentHoverCard
                                       data={extFilamentData}
                                       data={extFilamentData}
-                                      actions={renderAmsSlotActions({
-                                        amsId: 255,
-                                        slotId: slotTrayId,
-                                        loadTrayId: extTrayId,
-                                        includeRfid: false,
-                                      })}
+                                      actions={extActions}
                                       spoolman={{
                                       spoolman={{
                                         enabled: spoolmanEnabled,
                                         enabled: spoolmanEnabled,
                                         // #1457: slot assignment outranks tag-link (see top-level slot block).
                                         // #1457: slot assignment outranks tag-link (see top-level slot block).
@@ -6564,12 +6651,7 @@ function PrinterCard({
                                     <EmptySlotHoverCard
                                     <EmptySlotHoverCard
                                       kind={emptyKind ?? undefined}
                                       kind={emptyKind ?? undefined}
                                       assigned={pending ?? undefined}
                                       assigned={pending ?? undefined}
-                                      actions={renderAmsSlotActions({
-                                        amsId: 255,
-                                        slotId: slotTrayId,
-                                        loadTrayId: extTrayId,
-                                        includeRfid: false,
-                                      })}
+                                      actions={extActions}
                                       configureSlot={{
                                       configureSlot={{
                                         enabled: hasPermission('printers:control'),
                                         enabled: hasPermission('printers:control'),
                                         onConfigure: () => setConfigureSlotModal({
                                         onConfigure: () => setConfigureSlotModal({
@@ -6587,7 +6669,7 @@ function PrinterCard({
                                           material: undefined,
                                           material: undefined,
                                           profile: '',
                                           profile: '',
                                           color: '',
                                           color: '',
-                                          location: `External Slot ${slotTrayId + 1}`,
+                                          location: trayLayout.rack ? extLabel : `External Slot ${slotTrayId + 1}`,
                                           emptyKind,
                                           emptyKind,
                                         },
                                         },
                                       })}
                                       })}
@@ -6600,7 +6682,8 @@ function PrinterCard({
                             })}
                             })}
                           </div>
                           </div>
                         </div>
                         </div>
-                      )}
+                        );
+                      })()}
                   </div>
                   </div>
                 </div>
                 </div>
               );
               );
@@ -7490,6 +7573,11 @@ function PrinterCard({
               <div className="shrink-0 h-px bg-bambu-dark-tertiary" />
               <div className="shrink-0 h-px bg-bambu-dark-tertiary" />
               {/* Body */}
               {/* Body */}
               <div className="px-3 py-2.5 space-y-2.5 overflow-y-auto min-h-0">
               <div className="px-3 py-2.5 space-y-2.5 overflow-y-auto min-h-0">
+                {dryingPopoverDrySlots?.length === 2 && (
+                  <p className="text-[length:var(--pc-t10,10px)] text-bambu-gray" data-testid="drying-popover-ams3h-slots">
+                    {t('printers.drying.ams3hDryerSlots', { a: dryingPopoverDrySlots[0] + 1, b: dryingPopoverDrySlots[1] + 1 })}
+                  </p>
+                )}
                 {/* Filament type select */}
                 {/* Filament type select */}
                 <div>
                 <div>
                   <label className="text-[length:var(--pc-t10,10px)] text-white/70 font-medium mb-1 block">{t('printers.filaments')}</label>
                   <label className="text-[length:var(--pc-t10,10px)] text-white/70 font-medium mb-1 block">{t('printers.filaments')}</label>
@@ -7581,6 +7669,8 @@ function PrinterCard({
                   const targetAms = dryingPopoverAmsId !== null
                   const targetAms = dryingPopoverAmsId !== null
                     ? amsData.find(a => a.id === dryingPopoverAmsId)
                     ? amsData.find(a => a.id === dryingPopoverAmsId)
                     : undefined;
                     : undefined;
+                  // The AMS 3H can't turn its spools (the backend always sends rotate_tray false).
+                  if (isAms3hUnit(targetAms)) return null;
                   const trayLoadedInThisAms = amsHasLoadedTray(targetAms, status?.tray_now, status?.extruder_slots);
                   const trayLoadedInThisAms = amsHasLoadedTray(targetAms, status?.tray_now, status?.extruder_slots);
                   const rotateChecked = dryingRotateTray && !trayLoadedInThisAms;
                   const rotateChecked = dryingRotateTray && !trayLoadedInThisAms;
                   return (
                   return (
@@ -8129,6 +8219,9 @@ export function AddPrinterModal({
                   <option value="H2D Pro">H2D Pro</option>
                   <option value="H2D Pro">H2D Pro</option>
                   <option value="H2S">H2S</option>
                   <option value="H2S">H2S</option>
                 </optgroup>
                 </optgroup>
+                <optgroup label="H3 Series">
+                  <option value="H3C">H3C</option>
+                </optgroup>
                 <optgroup label="P Series">
                 <optgroup label="P Series">
                   <option value="P1P">P1P</option>
                   <option value="P1P">P1P</option>
                   <option value="P1S">P1S</option>
                   <option value="P1S">P1S</option>
@@ -8697,6 +8790,9 @@ function EditPrinterModal({
                   <option value="H2D Pro">H2D Pro</option>
                   <option value="H2D Pro">H2D Pro</option>
                   <option value="H2S">H2S</option>
                   <option value="H2S">H2S</option>
                 </optgroup>
                 </optgroup>
+                <optgroup label="H3 Series">
+                  <option value="H3C">H3C</option>
+                </optgroup>
                 <optgroup label="P Series">
                 <optgroup label="P Series">
                   <option value="P1P">P1P</option>
                   <option value="P1P">P1P</option>
                   <option value="P1S">P1S</option>
                   <option value="P1S">P1S</option>

+ 11 - 8
frontend/src/pages/QueuePage.tsx

@@ -73,7 +73,8 @@ import { getBedTypeInfo } from '../utils/bedType';
 import { getColorName } from '../utils/colors';
 import { getColorName } from '../utils/colors';
 import { productLineOf, useFilamentNameByIdx } from '../components/PrintModal/useFilamentLabels';
 import { productLineOf, useFilamentNameByIdx } from '../components/PrintModal/useFilamentLabels';
 import type { PrintQueueItem, PrintQueueBulkUpdate, Permission, CalibrationMode, PrinterStatus, SlotSpoolIdentity } from '../api/client';
 import type { PrintQueueItem, PrintQueueBulkUpdate, Permission, CalibrationMode, PrinterStatus, SlotSpoolIdentity } from '../api/client';
-import { formatSlotLabel, getEmptySlotKind } from '../utils/amsHelpers';
+import { formatSlotLabel, getEmptySlotKind, isAmsHtUnit } from '../utils/amsHelpers';
+import { locateTray, trayLayoutOf } from '../utils/trayIds';
 import type { PlateMetadata } from '../types/plates';
 import type { PlateMetadata } from '../types/plates';
 import { Card } from '../components/Card';
 import { Card } from '../components/Card';
 import { FilamentSwatch } from '../components/FilamentSwatch';
 import { FilamentSwatch } from '../components/FilamentSwatch';
@@ -119,16 +120,18 @@ type QueueFilamentDisplay = {
  * tray_type either, #2527), so the card only warns on a positive finding.
  * tray_type either, #2527), so the card only warns on a positive finding.
  */
  */
 function emptyMappedSlotLabel(status: PrinterStatus | undefined, trayId: number): string | undefined {
 function emptyMappedSlotLabel(status: PrinterStatus | undefined, trayId: number): string | undefined {
-  // The external holder reports no presence signal (the status route sends
-  // vt_tray without state / exists), so its emptiness can't be confirmed.
-  if (!status?.connected || trayId >= 254) return undefined;
-  const isHt = trayId >= 128;
-  const amsId = isHt ? trayId : Math.floor(trayId / 4);
-  const slot = isHt ? 0 : trayId % 4;
+  if (!status?.connected) return undefined;
+  // null for an external input: the external holder reports no presence signal
+  // (the status route sends vt_tray without state / exists), so its emptiness
+  // can't be confirmed.
+  const located = locateTray(trayLayoutOf(status.ams, status.vt_tray), trayId);
+  if (!located) return undefined;
+  const [amsId, slot] = located;
+  const isHt = isAmsHtUnit(amsId);
   const unit = status.ams?.find((ams) => ams.id === amsId);
   const unit = status.ams?.find((ams) => ams.id === amsId);
   const tray = isHt ? unit?.tray[0] : unit?.tray.find((candidate) => candidate.id === slot);
   const tray = isHt ? unit?.tray[0] : unit?.tray.find((candidate) => candidate.id === slot);
   if (getEmptySlotKind(tray) !== 'physical') return undefined;
   if (getEmptySlotKind(tray) !== 'physical') return undefined;
-  return formatSlotLabel(amsId, slot, isHt, false);
+  return formatSlotLabel(amsId, slot, isHt, false, unit?.ams_type);
 }
 }
 
 
 /**
 /**

+ 30 - 30
frontend/src/pages/spoolbuddy/SpoolBuddyAmsPage.tsx

@@ -6,7 +6,8 @@ import { Layers, Settings2, Package, Unlink, Link2, X } from 'lucide-react';
 import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
 import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
 import { api } from '../../api/client';
 import { api } from '../../api/client';
 import type { PrinterStatus, AMSTray, SpoolAssignment } from '../../api/client';
 import type { PrinterStatus, AMSTray, SpoolAssignment } from '../../api/client';
-import { getGlobalTrayId, getEmptySlotKind, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, formatSlotLabel, isBambuLabSpool, resolveSlotNozzleDiameter, slotPresetDescribesTray } from '../../utils/amsHelpers';
+import { getGlobalTrayId, getEmptySlotKind, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, formatSlotLabel, isAms3hUnit, isAmsHtUnit, isBambuLabSpool, rackExternalLabel, resolveSlotNozzleDiameter, slotPresetDescribesTray, spoolBuddyAmsName } from '../../utils/amsHelpers';
+import { externalSlotIndex, isRackAms, layoutAmsType, locateTray, trayLayoutOf } from '../../utils/trayIds';
 import { getSwatchStyle, resolveSpoolColorName } from '../../utils/colors';
 import { getSwatchStyle, resolveSpoolColorName } from '../../utils/colors';
 import { mapModelCode } from '../../utils/printerModel';
 import { mapModelCode } from '../../utils/printerModel';
 import { spoolSwatchStyle } from '../../components/spoolbuddy/spoolPaint';
 import { spoolSwatchStyle } from '../../components/spoolbuddy/spoolPaint';
@@ -35,12 +36,6 @@ import { LinkSpoolModal } from '../../components/LinkSpoolModal';
 import { useToast } from '../../contexts/ToastContext';
 import { useToast } from '../../contexts/ToastContext';
 import { useUiSettings } from '../../hooks/useUiSettings';
 import { useUiSettings } from '../../hooks/useUiSettings';
 
 
-function getAmsName(amsId: number): string {
-  if (amsId <= 3) return `AMS ${String.fromCharCode(65 + amsId)}`;
-  if (amsId >= 128 && amsId <= 135) return `AMS HT ${String.fromCharCode(65 + amsId - 128)}`;
-  return `AMS ${amsId}`;
-}
-
 function isTrayEmpty(tray: AMSTray): boolean {
 function isTrayEmpty(tray: AMSTray): boolean {
   return !tray.tray_type || tray.tray_type === '';
   return !tray.tray_type || tray.tray_type === '';
 }
 }
@@ -360,21 +355,20 @@ export function SpoolBuddyAmsPage() {
     },
     },
   });
   });
 
 
+  const trayLayout = useMemo(() => trayLayoutOf(status?.ams, status?.vt_tray), [status?.ams, status?.vt_tray]);
+
   const getActiveSlotForAms = useCallback((amsId: number): number | null => {
   const getActiveSlotForAms = useCallback((amsId: number): number | null => {
     if (effectiveTrayNow === undefined) return null;
     if (effectiveTrayNow === undefined) return null;
-    if (amsId <= 3) {
-      const activeAmsId = Math.floor(effectiveTrayNow / 4);
-      if (activeAmsId === amsId) return effectiveTrayNow % 4;
-    }
-    if (amsId >= 128 && amsId <= 135) {
-      // AMS-HT: global tray ID equals the AMS unit ID itself (128, 129, ...)
-      if (effectiveTrayNow === getGlobalTrayId(amsId, 0, false)) return 0;
+    if (amsId <= 3 || isAmsHtUnit(amsId) || isRackAms(layoutAmsType(trayLayout, amsId))) {
+      const active = locateTray(trayLayout, effectiveTrayNow);
+      if (active && active[0] === amsId) return active[1];
     }
     }
     return null;
     return null;
-  }, [effectiveTrayNow]);
+  }, [effectiveTrayNow, trayLayout]);
 
 
   const handleAmsSlotClick = useCallback((amsId: number, trayId: number, tray: AMSTray | null) => {
   const handleAmsSlotClick = useCallback((amsId: number, trayId: number, tray: AMSTray | null) => {
-    const globalTrayId = getGlobalTrayId(amsId, trayId, false);
+    const amsType = layoutAmsType(trayLayout, amsId);
+    const globalTrayId = getGlobalTrayId(amsId, trayId, false, amsType);
     const slotPreset = slotPresets?.[globalTrayId];
     const slotPreset = slotPresets?.[globalTrayId];
     const mappedExtruderId = amsExtruderMap[String(amsId)];
     const mappedExtruderId = amsExtruderMap[String(amsId)];
     const normalizedId = amsId >= 128 ? amsId - 128 : amsId;
     const normalizedId = amsId >= 128 ? amsId - 128 : amsId;
@@ -393,15 +387,15 @@ export function SpoolBuddyAmsPage() {
       savedPresetId: slotPresetDescribesTray(slotPreset?.preset_id, tray?.tray_info_idx, slotPreset?.tray_info_idx)
       savedPresetId: slotPresetDescribesTray(slotPreset?.preset_id, tray?.tray_info_idx, slotPreset?.tray_info_idx)
         ? slotPreset?.preset_id
         ? slotPreset?.preset_id
         : undefined,
         : undefined,
-      location: `${getAmsName(amsId)} Slot ${trayId + 1}`,
+      location: `${spoolBuddyAmsName(amsId, amsType)} Slot ${trayId + 1}`,
     };
     };
 
 
     setSlotActionPicker(slotData);
     setSlotActionPicker(slotData);
-  }, [slotPresets, amsExtruderMap, isDualNozzle]);
+  }, [slotPresets, amsExtruderMap, isDualNozzle, trayLayout]);
 
 
   const handleExtSlotClick = useCallback((extTray: AMSTray) => {
   const handleExtSlotClick = useCallback((extTray: AMSTray) => {
     const extTrayId = extTray.id ?? 254;
     const extTrayId = extTray.id ?? 254;
-    const slotTrayId = extTrayId - 254;
+    const slotTrayId = externalSlotIndex(extTrayId);
     const extSlotPreset = slotPresets?.[255 * 4 + slotTrayId];
     const extSlotPreset = slotPresets?.[255 * 4 + slotTrayId];
     const slotData = {
     const slotData = {
       amsId: 255,
       amsId: 255,
@@ -416,13 +410,15 @@ export function SpoolBuddyAmsPage() {
       savedPresetId: slotPresetDescribesTray(extSlotPreset?.preset_id, extTray.tray_info_idx, extSlotPreset?.tray_info_idx)
       savedPresetId: slotPresetDescribesTray(extSlotPreset?.preset_id, extTray.tray_info_idx, extSlotPreset?.tray_info_idx)
         ? extSlotPreset?.preset_id
         ? extSlotPreset?.preset_id
         : undefined,
         : undefined,
-      location: isDualNozzle
-        ? (extTrayId === 254 ? 'Ext-L' : 'Ext-R')
-        : 'External',
+      location: trayLayout.rack
+        ? rackExternalLabel(extTrayId)
+        : isDualNozzle
+          ? (extTrayId === 254 ? 'Ext-L' : 'Ext-R')
+          : 'External',
     };
     };
 
 
     setSlotActionPicker(slotData);
     setSlotActionPicker(slotData);
-  }, [slotPresets, isDualNozzle]);
+  }, [slotPresets, isDualNozzle, trayLayout.rack]);
 
 
   const openConfigureFromPicker = useCallback(() => {
   const openConfigureFromPicker = useCallback(() => {
     if (!slotActionPicker) return;
     if (!slotActionPicker) return;
@@ -518,7 +514,7 @@ export function SpoolBuddyAmsPage() {
       const resolvedInvFill = (invFill === 0 && amsFill !== null && amsFill > 0) ? null : invFill;
       const resolvedInvFill = (invFill === 0 && amsFill !== null && amsFill > 0) ? null : invFill;
       items.push({
       items.push({
         key: `ht-${unit.id}`,
         key: `ht-${unit.id}`,
-        label: getAmsName(unit.id),
+        label: spoolBuddyAmsName(unit.id),
         tray,
         tray,
         isEmpty: isTrayEmpty(tray),
         isEmpty: isTrayEmpty(tray),
         isActive: getActiveSlotForAms(unit.id) === 0,
         isActive: getActiveSlotForAms(unit.id) === 0,
@@ -539,7 +535,7 @@ export function SpoolBuddyAmsPage() {
         ? (extTrayId === 254 && status?.active_extruder === 1) ||
         ? (extTrayId === 254 && status?.active_extruder === 1) ||
           (extTrayId === 255 && status?.active_extruder === 0)
           (extTrayId === 255 && status?.active_extruder === 0)
         : effectiveTrayNow === extTrayId;
         : effectiveTrayNow === extTrayId;
-      const extSlotTrayId = extTrayId - 254;
+      const extSlotTrayId = externalSlotIndex(extTrayId);
       // Fill level fallback chain: Spoolman → Inventory → AMS remain
       // Fill level fallback chain: Spoolman → Inventory → AMS remain
       const extSpoolmanFill = getSpoolmanFillForSlot(255, extSlotTrayId, isTrayEmpty(extTray) ? null : extTray);
       const extSpoolmanFill = getSpoolmanFillForSlot(255, extSlotTrayId, isTrayEmpty(extTray) ? null : extTray);
       const extInvFill = fillOverrides[`255-${extSlotTrayId}`] ?? null;
       const extInvFill = fillOverrides[`255-${extSlotTrayId}`] ?? null;
@@ -548,9 +544,11 @@ export function SpoolBuddyAmsPage() {
       const extResolvedInvFill = (extInvFill === 0 && extAmsFill !== null && extAmsFill > 0) ? null : extInvFill;
       const extResolvedInvFill = (extInvFill === 0 && extAmsFill !== null && extAmsFill > 0) ? null : extInvFill;
       items.push({
       items.push({
         key: `ext-${extTrayId}`,
         key: `ext-${extTrayId}`,
-        label: isDualNozzle
-          ? (extTrayId === 254 ? t('printers.extL', 'Ext-L') : t('printers.extR', 'Ext-R'))
-          : t('printers.ext', 'Ext'),
+        label: trayLayout.rack
+          ? rackExternalLabel(extTrayId)
+          : isDualNozzle
+            ? (extTrayId === 254 ? t('printers.extL', 'Ext-L') : t('printers.extR', 'Ext-R'))
+            : t('printers.ext', 'Ext'),
         tray: extTray,
         tray: extTray,
         isEmpty: isTrayEmpty(extTray),
         isEmpty: isTrayEmpty(extTray),
         isActive: isExtActive,
         isActive: isExtActive,
@@ -561,7 +559,7 @@ export function SpoolBuddyAmsPage() {
     }
     }
 
 
     return items;
     return items;
-  }, [htAms, vtTrays, isDualNozzle, effectiveTrayNow, status?.active_extruder, t, getActiveSlotForAms, getNozzleSide, handleAmsSlotClick, handleExtSlotClick, fillOverrides, getSpoolmanFillForSlot]);
+  }, [htAms, vtTrays, isDualNozzle, effectiveTrayNow, status?.active_extruder, t, getActiveSlotForAms, getNozzleSide, handleAmsSlotClick, handleExtSlotClick, fillOverrides, getSpoolmanFillForSlot, trayLayout.rack]);
 
 
   return (
   return (
     <div className="h-full flex flex-col p-4">
     <div className="h-full flex flex-col p-4">
@@ -592,8 +590,9 @@ export function SpoolBuddyAmsPage() {
             {/* Regular AMS cards — 4-slot, 2-col grid */}
             {/* Regular AMS cards — 4-slot, 2-col grid */}
             <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
             <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
               {regularAms.map((unit) => (
               {regularAms.map((unit) => (
+                // The AMS 3H takes the full width for its nine slots.
+                <div key={unit.id} className={isAms3hUnit(unit) ? 'md:col-span-2' : 'contents'}>
                 <AmsUnitCard
                 <AmsUnitCard
-                  key={unit.id}
                   unit={unit}
                   unit={unit}
                   activeSlot={getActiveSlotForAms(unit.id)}
                   activeSlot={getActiveSlotForAms(unit.id)}
                   onConfigureSlot={handleAmsSlotClick}
                   onConfigureSlot={handleAmsSlotClick}
@@ -603,6 +602,7 @@ export function SpoolBuddyAmsPage() {
                   fillOverrides={fillOverrides}
                   fillOverrides={fillOverrides}
                   spoolmanFillOverrides={spoolmanFillOverrides}
                   spoolmanFillOverrides={spoolmanFillOverrides}
                 />
                 />
+                </div>
               ))}
               ))}
             </div>
             </div>
 
 

+ 5 - 7
frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx

@@ -8,7 +8,7 @@ import type { InventorySpool } from '../../api/client';
 import { resolveSpoolColorName, getSwatchStyle, spoolColorString } from '../../utils/colors';
 import { resolveSpoolColorName, getSwatchStyle, spoolColorString } from '../../utils/colors';
 import { SpoolPaint, SpoolShade } from '../../components/spoolbuddy/SpoolIcon';
 import { SpoolPaint, SpoolShade } from '../../components/spoolbuddy/SpoolIcon';
 import { hasFilamentPaint, spoolSwatchStyle } from '../../components/spoolbuddy/spoolPaint';
 import { hasFilamentPaint, spoolSwatchStyle } from '../../components/spoolbuddy/spoolPaint';
-import { formatSlotLabel } from '../../utils/amsHelpers';
+import { formatAssignmentSlotLabel } from '../../utils/amsHelpers';
 import { filterSpoolsByQuery } from '../../utils/inventorySearch';
 import { filterSpoolsByQuery } from '../../utils/inventorySearch';
 import { useColorCatalogVersion } from '../../hooks/useColorCatalogVersion';
 import { useColorCatalogVersion } from '../../hooks/useColorCatalogVersion';
 import { InventorySpoolInfoCard } from '../../components/spoolbuddy/InventorySpoolInfoCard';
 import { InventorySpoolInfoCard } from '../../components/spoolbuddy/InventorySpoolInfoCard';
@@ -17,7 +17,7 @@ import { QrAssignTargetModal } from '../../components/QrAssignTargetModal';
 import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
 import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
 import { useToast } from '../../contexts/ToastContext';
 import { useToast } from '../../contexts/ToastContext';
 
 
-type SlotInfo = { ams_id: number; tray_id: number; printer_name?: string | null };
+type SlotInfo = { ams_id: number; tray_id: number; printer_name?: string | null; printer_model?: string | null };
 
 
 type FilterMode = 'all' | 'in_ams' | string; // string = material name
 type FilterMode = 'all' | 'in_ams' | string; // string = material name
 
 
@@ -41,9 +41,7 @@ function spoolDisplayName(spool: InventorySpool): string {
 }
 }
 
 
 function assignmentLabel(a: SlotInfo): string {
 function assignmentLabel(a: SlotInfo): string {
-  const isExternal = a.ams_id === 254 || a.ams_id === 255;
-  const isHt = !isExternal && a.ams_id >= 128;
-  return formatSlotLabel(a.ams_id, a.tray_id, isHt, isExternal);
+  return formatAssignmentSlotLabel(a);
 }
 }
 
 
 /* Spool circle — same style as AMS page tray slots */
 /* Spool circle — same style as AMS page tray slots */
@@ -137,11 +135,11 @@ export function SpoolBuddyInventoryPage() {
   const assignmentMap = useMemo((): Record<number, SlotInfo> => {
   const assignmentMap = useMemo((): Record<number, SlotInfo> => {
     if (spoolmanMode) {
     if (spoolmanMode) {
       const map: Record<number, SlotInfo> = {};
       const map: Record<number, SlotInfo> = {};
-      spoolmanAssignments.forEach(a => { map[a.spoolman_spool_id] = { ams_id: a.ams_id, tray_id: a.tray_id }; });
+      spoolmanAssignments.forEach(a => { map[a.spoolman_spool_id] = { ams_id: a.ams_id, tray_id: a.tray_id, printer_model: a.printer_model }; });
       return map;
       return map;
     }
     }
     const map: Record<number, SlotInfo> = {};
     const map: Record<number, SlotInfo> = {};
-    assignments.forEach(a => { map[a.spool_id] = { ams_id: a.ams_id, tray_id: a.tray_id, printer_name: a.printer_name }; });
+    assignments.forEach(a => { map[a.spool_id] = { ams_id: a.ams_id, tray_id: a.tray_id, printer_name: a.printer_name, printer_model: a.printer_model }; });
     return map;
     return map;
   }, [spoolmanMode, assignments, spoolmanAssignments]);
   }, [spoolmanMode, assignments, spoolmanAssignments]);
 
 

+ 93 - 14
frontend/src/utils/amsHelpers.ts

@@ -5,6 +5,18 @@
  */
  */
 import { parseUTCDate } from './date';
 import { parseUTCDate } from './date';
 import { settingIdToFilamentId } from './filamentIds';
 import { settingIdToFilamentId } from './filamentIds';
+import {
+  AmsType,
+  EXTERNAL_AMS_ID,
+  RACK_SLOT_COUNT,
+  externalHotend,
+  externalTrayId,
+  globalTrayId as studioGlobalTrayId,
+  isAmsHtId,
+  isRackAms,
+  trayLayoutOf,
+  unitAmsType,
+} from './trayIds';
 
 
 /**
 /**
  * Normalize color format from various sources for CSS rendering.
  * Normalize color format from various sources for CSS rendering.
@@ -74,12 +86,15 @@ export function resolveSlotExtruder(
   return inlet ? FTS_INLET_EXTRUDER[inlet.toUpperCase()] : undefined;
   return inlet ? FTS_INLET_EXTRUDER[inlet.toUpperCase()] : undefined;
 }
 }
 
 
+const AMS3H_LABEL = 'AMS 3H';
+
 /**
 /**
  * AMS unit label using the codebase convention: "AMS-A / AMS-B / ..." for
  * AMS unit label using the codebase convention: "AMS-A / AMS-B / ..." for
  * regular AMS, "HT-A / HT-B / ..." for AMS-HT (single-tray modules with
  * regular AMS, "HT-A / HT-B / ..." for AMS-HT (single-tray modules with
- * IDs 128-135). The unit id decides: a regular AMS can report one tray.
+ * IDs 128-135), "AMS 3H" for the H3C's. The unit id decides: a regular AMS can
+ * report one tray.
  */
  */
-export function getAmsLabel(amsId: number | string): string {
+export function getAmsLabel(amsId: number | string, amsType?: number | null): string {
   const id = typeof amsId === 'string' ? parseInt(amsId, 10) : amsId;
   const id = typeof amsId === 'string' ? parseInt(amsId, 10) : amsId;
   const safeId = isNaN(id) ? 0 : id;
   const safeId = isNaN(id) ? 0 : id;
   if (safeId === 255) return 'External';
   if (safeId === 255) return 'External';
@@ -87,12 +102,73 @@ export function getAmsLabel(amsId: number | string): string {
   // ingest (see a2l-am-unit-16). No regular AMS uses id 6, so this is a safe,
   // ingest (see a2l-am-unit-16). No regular AMS uses id 6, so this is a safe,
   // self-scoping label for the Lite's 4-slot unit.
   // self-scoping label for the Lite's 4-slot unit.
   if (safeId === 6) return 'AMS Lite';
   if (safeId === 6) return 'AMS Lite';
+  if (isAms3hUnit({ id: safeId, ams_type: amsType })) return AMS3H_LABEL;
   const isHt = isAmsHtUnit(safeId);
   const isHt = isAmsHtUnit(safeId);
   const normalizedId = safeId >= 128 ? safeId - 128 : safeId;
   const normalizedId = safeId >= 128 ? safeId - 128 : safeId;
   const letter = String.fromCharCode(65 + normalizedId);
   const letter = String.fromCharCode(65 + normalizedId);
   return isHt ? `HT-${letter}` : `AMS-${letter}`;
   return isHt ? `HT-${letter}` : `AMS-${letter}`;
 }
 }
 
 
+/** The H3C's AMS 3H: one nine-slot rack AMS whose slot k feeds hotend k. */
+export function isAms3hUnit(unit: { id: number; ams_type?: number | null } | null | undefined): boolean {
+  return !!unit && unitAmsType(unit.id, unit.ams_type) === AmsType.ZMULTI_A;
+}
+
+/** How many slots a unit's card draws: nine for a rack AMS, one for an AMS-HT, else four. */
+export function amsUnitSlotCount(unit: { id: number; ams_type?: number | null }): number {
+  if (isAmsHtUnit(unit.id)) return 1;
+  return isRackAms(unitAmsType(unit.id, unit.ams_type)) ? RACK_SLOT_COUNT : 4;
+}
+
+/**
+ * An AMS 3H slot's place on a 10-column card grid, as on the unit and in Bambu
+ * Studio: 2, 4, 6, 8 in the back row, the odd slots in front, each back slot
+ * half a slot to the right of the front one before it.
+ */
+export function ams3hSlotGridPlacement(slotNumber: number): { gridRow: number; gridColumn: string } {
+  return { gridRow: slotNumber % 2 === 0 ? 1 : 2, gridColumn: `${slotNumber} / span 2` };
+}
+
+/**
+ * Back-row grid columns of the AMS 3H dryer box around its slots (tray ids
+ * 0-8). When it ends at slot 8 the free column 10 holds the dryer's readings,
+ * as in Bambu Studio. Null unless every dryer slot is a back-row one.
+ */
+export function ams3hDryerGridColumns(drySlots: ReadonlyArray<number> | null | undefined): { start: number; end: number; info: number | null } | null {
+  const numbers = (drySlots ?? []).map((slot) => slot + 1);
+  if (numbers.length === 0 || numbers.some((n) => n % 2 !== 0 || n < 2 || n > 8)) return null;
+  const first = Math.min(...numbers);
+  const last = Math.max(...numbers);
+  const info = last === 8 ? 10 : null;
+  return { start: first, end: (info ?? last + 1) + 1, info };
+}
+
+/** A rack printer's external input as Bambu Studio names it: "Ext-n" feeds hotend n. */
+export function rackExternalLabel(trayId: number): string {
+  return `Ext-${externalHotend(trayId) + 1}`;
+}
+
+/**
+ * Compact slot label for a spool assignment: "B3" / "HT-A" / "Ext", "3H-7" for
+ * an AMS 3H slot and "Ext-n" for a rack printer's external input.
+ */
+export function formatAssignmentSlotLabel(a: { ams_id: number; tray_id: number; printer_model?: string | null }): string {
+  const isExternal = a.ams_id === 254 || a.ams_id === EXTERNAL_AMS_ID;
+  const isHt = !isExternal && a.ams_id >= 128;
+  if (a.ams_id === EXTERNAL_AMS_ID && trayLayoutOf(null, null, a.printer_model).rack) {
+    return rackExternalLabel(externalTrayId(a.tray_id));
+  }
+  return formatSlotLabel(a.ams_id, a.tray_id, isHt, isExternal);
+}
+
+/** SpoolBuddy's unit name: "AMS A", "AMS HT A", "AMS 3H". */
+export function spoolBuddyAmsName(amsId: number, amsType?: number | null): string {
+  if (amsId <= 3) return `AMS ${String.fromCharCode(65 + amsId)}`;
+  if (isAmsHtUnit(amsId)) return `AMS HT ${String.fromCharCode(65 + amsId - 128)}`;
+  if (isAms3hUnit({ id: amsId, ams_type: amsType })) return AMS3H_LABEL;
+  return `AMS ${amsId}`;
+}
+
 /**
 /**
  * Filament type equivalence groups.
  * Filament type equivalence groups.
  * Types within the same group are interchangeable on the printer side
  * Types within the same group are interchangeable on the printer side
@@ -362,9 +438,11 @@ export function formatSlotLabel(
   amsId: number,
   amsId: number,
   trayId: number,
   trayId: number,
   isHt: boolean,
   isHt: boolean,
-  isExternal: boolean
+  isExternal: boolean,
+  amsType?: number | null,
 ): string {
 ): string {
   if (isExternal) return 'Ext';
   if (isExternal) return 'Ext';
+  if (!isHt && isAms3hUnit({ id: amsId, ams_type: amsType })) return `3H-${trayId + 1}`;
   // Convert AMS ID to letter (A, B, C, D)
   // Convert AMS ID to letter (A, B, C, D)
   // AMS-HT uses IDs starting at 128
   // AMS-HT uses IDs starting at 128
   const letter = String.fromCharCode(65 + (amsId >= 128 ? amsId - 128 : amsId));
   const letter = String.fromCharCode(65 + (amsId >= 128 ? amsId - 128 : amsId));
@@ -378,17 +456,17 @@ export function formatSlotLabel(
  * @param amsId - AMS unit ID (0-3 for regular AMS, 128+ for AMS-HT)
  * @param amsId - AMS unit ID (0-3 for regular AMS, 128+ for AMS-HT)
  * @param trayId - Tray/slot ID within the AMS unit
  * @param trayId - Tray/slot ID within the AMS unit
  * @param isExternal - Whether this is the external spool holder
  * @param isExternal - Whether this is the external spool holder
- * @returns Global tray ID (0-15 for AMS, 128+ for AMS-HT, 254 for external)
+ * @param amsType - The unit's `ams_type`, when known
+ * @returns Global tray ID (see utils/trayIds: 0-15 for AMS, 28-36 for the AMS 3H, 128+ for AMS-HT, 254 for external)
  */
  */
 export function getGlobalTrayId(
 export function getGlobalTrayId(
   amsId: number,
   amsId: number,
   trayId: number,
   trayId: number,
-  isExternal: boolean
+  isExternal: boolean,
+  amsType?: number | null,
 ): number {
 ): number {
-  if (isExternal) return 254 + trayId;
-  // AMS-HT units have IDs starting at 128 with a single tray — use ID directly
-  if (amsId >= 128) return amsId;
-  return amsId * 4 + trayId;
+  if (isExternal) return externalTrayId(trayId);
+  return studioGlobalTrayId(amsId, trayId, amsType);
 }
 }
 
 
 /**
 /**
@@ -396,7 +474,7 @@ export function getGlobalTrayId(
  * count: a regular AMS can report fewer than four trays.
  * count: a regular AMS can report fewer than four trays.
  */
  */
 export function isAmsHtUnit(amsId: number): boolean {
 export function isAmsHtUnit(amsId: number): boolean {
-  return amsId >= 128 && amsId <= 135;
+  return isAmsHtId(amsId);
 }
 }
 
 
 /**
 /**
@@ -421,13 +499,13 @@ export function trayForSlot<T extends { id: number }>(trays: T[], slotIdx: numbe
  * the active nozzle's.
  * the active nozzle's.
  */
  */
 export function amsHasLoadedTray(
 export function amsHasLoadedTray(
-  ams: { id: number; tray: { id: number; state?: number | null }[] } | undefined,
+  ams: { id: number; ams_type?: number | null; tray: { id: number; state?: number | null }[] } | undefined,
   trayNow: number | null | undefined,
   trayNow: number | null | undefined,
   extruderSlots: Record<string, { ams_id: number | null; has_filament: boolean }> | null | undefined,
   extruderSlots: Record<string, { ams_id: number | null; has_filament: boolean }> | null | undefined,
 ): boolean {
 ): boolean {
   if (!ams) return false;
   if (!ams) return false;
   if (ams.tray.some(tray => tray.state === 11)) return true;
   if (ams.tray.some(tray => tray.state === 11)) return true;
-  if (trayNow != null && ams.tray.some(tray => getGlobalTrayId(ams.id, tray.id, false) === trayNow)) return true;
+  if (trayNow != null && ams.tray.some(tray => getGlobalTrayId(ams.id, tray.id, false, ams.ams_type) === trayNow)) return true;
   return Object.values(extruderSlots ?? {}).some(slot => slot.ams_id === ams.id && slot.has_filament);
   return Object.values(extruderSlots ?? {}).some(slot => slot.ams_id === ams.id && slot.has_filament);
 }
 }
 
 
@@ -499,7 +577,7 @@ export function isPlaceholderDate(scheduledTime: string | null | undefined): boo
 function slotPriority(amsId: number | undefined, trayId: number | undefined): number {
 function slotPriority(amsId: number | undefined, trayId: number | undefined): number {
   if (amsId == null || amsId < 0) return 10_000;
   if (amsId == null || amsId < 0) return 10_000;
   if (amsId >= 128) return 1_000 + (amsId - 128) * 4 + (trayId ?? 0);
   if (amsId >= 128) return 1_000 + (amsId - 128) * 4 + (trayId ?? 0);
-  return amsId * 4 + (trayId ?? 0);
+  return studioGlobalTrayId(amsId, trayId ?? 0);
 }
 }
 
 
 /**
 /**
@@ -735,6 +813,7 @@ export interface AmsTrayLike {
 
 
 export interface AmsUnitLike {
 export interface AmsUnitLike {
   id: number;
   id: number;
+  ams_type?: number | null;
   tray: AmsTrayLike[];
   tray: AmsTrayLike[];
 }
 }
 
 
@@ -817,7 +896,7 @@ export function computeBackupGroups(
     ams.tray.forEach((tray, slotIdx) => {
     ams.tray.forEach((tray, slotIdx) => {
       if (!tray?.tray_type) return; // empty slot
       if (!tray?.tray_type) return; // empty slot
       const preset = (tray.tray_info_idx || '').trim();
       const preset = (tray.tray_info_idx || '').trim();
-      const globalTrayId = getGlobalTrayId(ams.id, slotIdx, false);
+      const globalTrayId = getGlobalTrayId(ams.id, slotIdx, false, ams.ams_type);
       const member = { amsId: ams.id, slotIdx, globalTrayId };
       const member = { amsId: ams.id, slotIdx, globalTrayId };
 
 
       let key: string;
       let key: string;

+ 4 - 0
frontend/src/utils/maintenanceWikiUrls.ts

@@ -6,6 +6,8 @@
  *   - P2S, X2D       → hardened steel rods (X2D shares P2S's gantry — #988)
  *   - P2S, X2D       → hardened steel rods (X2D shares P2S's gantry — #988)
  *   - A1, A1 Mini    → linear rails (Y axis)
  *   - A1, A1 Mini    → linear rails (Y axis)
  *   - H2D, H2C, H2S  → linear rails (X-axis lubrication)
  *   - H2D, H2C, H2S  → linear rails (X-axis lubrication)
+ *   - H3C            → its own tasks carry their wiki links; only the shared
+ *                      tasks (belt tension) resolve here
  *
  *
  * Returns null when no wiki page applies (e.g. "Clean Carbon Rods" on an H2D),
  * Returns null when no wiki page applies (e.g. "Clean Carbon Rods" on an H2D),
  * which the caller renders as a task with no clickable help link.
  * which the caller renders as a task with no clickable help link.
@@ -21,6 +23,7 @@ export function getMaintenanceWikiUrl(typeName: string, printerModel: string | n
   const isH2C = model.includes('H2C');
   const isH2C = model.includes('H2C');
   const isH2S = model.includes('H2S');
   const isH2S = model.includes('H2S');
   const isH2 = isH2D || isH2C || isH2S;
   const isH2 = isH2D || isH2C || isH2S;
+  const isH3C = model.includes('H3C') || model === 'N10';
   const isP2S = model.includes('P2S');
   const isP2S = model.includes('P2S');
   const isX2D = model.includes('X2D');
   const isX2D = model.includes('X2D');
   // X2D shares the hardened steel rod hardware and belt layout with P2S,
   // X2D shares the hardened steel rod hardware and belt layout with P2S,
@@ -56,6 +59,7 @@ export function getMaintenanceWikiUrl(typeName: string, printerModel: string | n
       if (isH2D) return 'https://wiki.bambulab.com/en/h2/maintenance/belt-tension';
       if (isH2D) return 'https://wiki.bambulab.com/en/h2/maintenance/belt-tension';
       if (isH2C) return 'https://wiki.bambulab.com/en/h2c/maintenance/belt-tension';
       if (isH2C) return 'https://wiki.bambulab.com/en/h2c/maintenance/belt-tension';
       if (isH2S) return 'https://wiki.bambulab.com/en/h2s/maintenance/belt-tension';
       if (isH2S) return 'https://wiki.bambulab.com/en/h2s/maintenance/belt-tension';
+      if (isH3C) return 'https://wiki.bambulab.com/en/h3c/maintenance/belt_tension';
       if (isSteelRod) return 'https://wiki.bambulab.com/en/p2s/maintenance/belt-tension';
       if (isSteelRod) return 'https://wiki.bambulab.com/en/p2s/maintenance/belt-tension';
       // X1 and P1 share one page, "X1/P1 Series Belt tensioning procedure";
       // X1 and P1 share one page, "X1/P1 Series Belt tensioning procedure";
       // P1 has none of its own, so it must not get a branch above (#3302).
       // P1 has none of its own, so it must not get a branch above (#3302).

+ 2 - 0
frontend/src/utils/printer.ts

@@ -8,6 +8,8 @@ export function getPrinterImage(model: string | null | undefined): string {
   if (m.includes('h2dpro') || m.includes('h2d-pro')) return '/img/printers/h2dpro.png';
   if (m.includes('h2dpro') || m.includes('h2d-pro')) return '/img/printers/h2dpro.png';
   if (m.includes('h2d')) return '/img/printers/h2d.png';
   if (m.includes('h2d')) return '/img/printers/h2d.png';
   if (m.includes('h2c')) return '/img/printers/h2c.png';
   if (m.includes('h2c')) return '/img/printers/h2c.png';
+  // Bambu Studio 02.09's own H3C render (resources/images/printer_thumbnail_N10_png.png).
+  if (m.includes('h3c') || m === 'n10') return '/img/printers/h3c.png';
   if (m.includes('h2s')) return '/img/printers/h2d.png';
   if (m.includes('h2s')) return '/img/printers/h2d.png';
   if (m.includes('p2s')) return '/img/printers/p2s.png';
   if (m.includes('p2s')) return '/img/printers/p2s.png';
   if (m.includes('p1s')) return '/img/printers/p1s.png';
   if (m.includes('p1s')) return '/img/printers/p1s.png';

+ 3 - 0
frontend/src/utils/printerModel.ts

@@ -9,6 +9,8 @@ export function mapModelCode(ssdpModel: string | null): string {
     'O1C': 'H2C',
     'O1C': 'H2C',
     'O1C2': 'H2C',
     'O1C2': 'H2C',
     'O1S': 'H2S',
     'O1S': 'H2S',
+    // H3 Series (model_id in BambuStudio's "Bambu Lab H3C.json")
+    'N10': 'H3C',
     // X1 Series
     // X1 Series
     'BL-P001': 'X1C',
     'BL-P001': 'X1C',
     'BL-P002': 'X1',
     'BL-P002': 'X1',
@@ -44,6 +46,7 @@ export function mapModelCode(ssdpModel: string | null): string {
     'H2D Pro': 'H2D Pro',
     'H2D Pro': 'H2D Pro',
     'H2C': 'H2C',
     'H2C': 'H2C',
     'H2S': 'H2S',
     'H2S': 'H2S',
+    'H3C': 'H3C',
   };
   };
   return modelMap[ssdpModel] || ssdpModel;
   return modelMap[ssdpModel] || ssdpModel;
 }
 }

+ 145 - 0
frontend/src/utils/trayIds.ts

@@ -0,0 +1,145 @@
+/**
+ * Bambu Studio's global tray numbering (DevFilaSystem::GetTrayIndexMap), the
+ * twin of backend/app/utils/tray_ids.py. A global tray id is what tray_now and
+ * a print's ams_mapping hold:
+ *
+ *   AMS / AMS Lite / AMS 2 Pro   ams_id * 4 + slot
+ *   A2L AMS Lite                 24 + slot
+ *   AMS 3H (rack AMS, type 6)    28 + slot   (28-36)
+ *   AMS-B (rack AMS, type 7)     37 + slot
+ *   AMS-HT (units 128-135)       ams_id
+ *   external input               254 / 255, and 247-255 on a rack printer
+ *
+ * A rack printer (the H3C) has one external input per hotend: input v feeds
+ * hotend 255 - v. Bambuddy stores a slot as (ams_id, tray_id), external inputs
+ * under ams_id 255 (tray_id 0 = 254, 1 = 255, 2-8 = 247-253).
+ */
+
+export const AmsType = {
+  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
+  ZMULTI_B: 7, // AMS-B
+} as const;
+
+const RACK_TRAY_BASE: Record<number, number> = { [AmsType.ZMULTI_A]: 28, [AmsType.ZMULTI_B]: 37 };
+const AMS_LITE_MIXED_TRAY_BASE = 24;
+/** One rack AMS slot per hotend of the rack, nine on the H3C. */
+export const RACK_SLOT_COUNT = 9;
+// The H3C reports its AMS 3H as unit 32; read only when a unit's type or the AMS data is missing.
+const AMS3H_UNIT_ID = 32;
+// For callers with no live status only (see trayLayoutOf).
+const RACK_PRINTER_MODELS = new Set(['H3C', 'N10']);
+
+export const EXTERNAL_AMS_ID = 255;
+const EXTERNAL_MAIN_ID = 255;
+const EXTERNAL_DEPUTY_ID = 254;
+const RACK_EXTERNAL_FIRST = 247;
+
+export function isRackAms(amsType: number | null | undefined): boolean {
+  return amsType === AmsType.ZMULTI_A || amsType === AmsType.ZMULTI_B;
+}
+
+export function isAmsHtId(amsId: number): boolean {
+  return amsId >= 128 && amsId <= 135;
+}
+
+/** Unit `amsId`'s AMS type: its `ams_type` when set, else from the id (128+ AMS-HT, 32 AMS 3H, else AMS). */
+export function unitAmsType(amsId: number, explicit?: number | null): number {
+  if (isAmsHtId(amsId)) return AmsType.N3S;
+  if (typeof explicit === 'number' && explicit > AmsType.EXT_SPOOL && explicit <= AmsType.ZMULTI_B) return explicit;
+  if (amsId >= 128) return AmsType.N3S;
+  if (amsId === AMS3H_UNIT_ID) return AmsType.ZMULTI_A;
+  return AmsType.AMS;
+}
+
+/** Global tray id of slot `slot` of AMS unit `amsId` (AMS slots only, see externalTrayId). */
+export function globalTrayId(amsId: number, slot: number, amsType?: number | null): number {
+  const kind = unitAmsType(amsId, amsType);
+  if (kind === AmsType.N3S) return amsId;
+  if (kind === AmsType.AMS_LITE_MIXED) return AMS_LITE_MIXED_TRAY_BASE + slot;
+  if (RACK_TRAY_BASE[kind] !== undefined) return RACK_TRAY_BASE[kind] + slot;
+  return amsId * 4 + slot;
+}
+
+export function isExternalTray(trayId: number | null | undefined, rack = false): boolean {
+  if (typeof trayId !== 'number') return false;
+  if (trayId === EXTERNAL_DEPUTY_ID || trayId === EXTERNAL_MAIN_ID) return true;
+  return rack && trayId >= RACK_EXTERNAL_FIRST && trayId <= EXTERNAL_MAIN_ID;
+}
+
+/** The hotend (rack position 0-8) external input `trayId` feeds on a rack printer. */
+export function externalHotend(trayId: number): number {
+  return EXTERNAL_MAIN_ID - trayId;
+}
+
+/** The tray_id an external input is stored under: 254 -> 0, 255 -> 1, 247-253 -> 2-8. */
+export function externalSlotIndex(trayId: number): number {
+  if (trayId === EXTERNAL_DEPUTY_ID || trayId === EXTERNAL_MAIN_ID) return trayId - EXTERNAL_DEPUTY_ID;
+  return trayId - RACK_EXTERNAL_FIRST + 2;
+}
+
+export function externalTrayId(slotIndex: number): number {
+  if (slotIndex === 0 || slotIndex === 1) return EXTERNAL_DEPUTY_ID + slotIndex;
+  return RACK_EXTERNAL_FIRST + slotIndex - 2;
+}
+
+export interface TrayLayout {
+  units: { id: number; type: number }[];
+  /** A rack printer: per-hotend external inputs 247-255. */
+  rack: boolean;
+}
+
+/**
+ * How one printer numbers its trays, and the one place that decides whether it
+ * is a rack printer: it has a rack AMS (by `ams_type`, unit 32 when that is
+ * missing) or reports an external input 247-253. `model` is a last resort for
+ * callers without live status (a stored assignment): with no AMS units listed,
+ * an H3C gets its AMS 3H, like the backend's TrayLayout.from_state.
+ */
+export function trayLayoutOf(
+  units: ReadonlyArray<{ id: number; ams_type?: number | null }> | null | undefined,
+  vtTrays?: ReadonlyArray<{ id?: number | null }> | null,
+  model?: string | null,
+): TrayLayout {
+  const typed: TrayLayout['units'] = [];
+  for (const unit of units ?? []) {
+    if (!typed.some((known) => known.id === unit.id)) typed.push({ id: unit.id, type: unitAmsType(unit.id, unit.ams_type) });
+  }
+  if (typed.length === 0 && model && RACK_PRINTER_MODELS.has(model.toUpperCase())) {
+    return { units: [{ id: AMS3H_UNIT_ID, type: AmsType.ZMULTI_A }], rack: true };
+  }
+  const rack = typed.some((unit) => isRackAms(unit.type))
+    || (vtTrays ?? []).some((vt) => typeof vt.id === 'number' && vt.id >= RACK_EXTERNAL_FIRST && vt.id < EXTERNAL_DEPUTY_ID);
+  return { units: typed, rack };
+}
+
+/** Type of unit `amsId` on the printer `layout` describes; told from the id when it isn't listed. */
+export function layoutAmsType(layout: TrayLayout | null | undefined, amsId: number): number {
+  return layout?.units.find((unit) => unit.id === amsId)?.type ?? unitAmsType(amsId);
+}
+
+/**
+ * [ams_id, slot] of an AMS slot's global tray id, as the backend's slot_key
+ * stores it; null for an external input (247-255) or no tray. Unless a rack
+ * AMS or a unit 7 is listed, 28-36 are the AMS 3H's, as in Bambu Studio.
+ */
+export function locateTray(layout: TrayLayout | null | undefined, trayId: number): [number, number] | null {
+  if (!Number.isInteger(trayId) || trayId < 0 || isExternalTray(trayId, true)) return null;
+  if (trayId >= 128) return [trayId, 0];
+  const units = layout?.units ?? [];
+  const rackUnits = units.filter((unit) => isRackAms(unit.type));
+  for (const unit of rackUnits) {
+    const base = RACK_TRAY_BASE[unit.type];
+    if (trayId >= base && trayId < base + RACK_SLOT_COUNT) return [unit.id, trayId - base];
+  }
+  const ams3hBase = RACK_TRAY_BASE[AmsType.ZMULTI_A];
+  if (rackUnits.length === 0 && !units.some((unit) => unit.id === 7) && trayId >= ams3hBase && trayId < ams3hBase + RACK_SLOT_COUNT) {
+    return [AMS3H_UNIT_ID, trayId - ams3hBase];
+  }
+  return [Math.floor(trayId / 4), trayId % 4];
+}