Bläddra i källkod

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 dagar sedan
förälder
incheckning
c8fb2c7ead
85 ändrade filer med 4863 tillägg och 619 borttagningar
  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.natural_sort import natural_sort_key
 from backend.app.utils.tag_normalization import normalize_tag_uid, normalize_tray_uuid
+from backend.app.utils.tray_ids import external_tray_id
 
 logger = logging.getLogger(__name__)
 
@@ -2460,7 +2461,7 @@ async def assign_spool(
         if data.ams_id == 255:
             # External slot: look up tray from vt_tray by global ID
             vt_tray = state.raw_data.get("vt_tray") or []
-            ext_id = data.tray_id + 254  # 0→254, 1→255
+            ext_id = external_tray_id(data.tray_id)  # 0→254, 1→255, rack 2-8→247-253
             for vt in vt_tray:
                 if isinstance(vt, dict) and int(vt.get("id", 254)) == ext_id:
                     fingerprint_color = vt.get("tray_color", "")

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

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

+ 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.kprofile_lookup import build_slot_k_resolver
 from backend.app.utils.printer_models import MAX_CHAMBER_TEMP_C, aux_fan_sides, uses_exhaust_fan_label
+from backend.app.utils.tray_ids import (
+    AMS_HT_IDS,
+    EXTERNAL_AMS_ID,
+    TrayLayout,
+    external_tray_id,
+    global_tray_id as studio_global_tray_id,
+    is_ams_ht_id,
+    legacy_tray_index,
+)
 
 logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/printers", tags=["printers"])
@@ -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)
     if "vt_tray" in raw_data:
         vt_tray = [AMSTray(**vt) for vt in vt_trays_to_dicts(raw_data["vt_tray"], _kprofile_k)]
+    tray_layout = TrayLayout.from_units(ams_units, vt_tray)
 
     # Convert nozzle info to response format
     nozzles = [
@@ -790,6 +800,7 @@ async def _printer_status(db: AsyncSession, printer: Printer) -> PrinterStatus:
             filament_color=n.get("filament_color", ""),
             filament_id=n.get("filament_id", ""),
             filament_type=n.get("filament_type", ""),
+            addr_id=n.get("addr_id"),
         )
         for n in (state.nozzle_rack or [])
     ]
@@ -912,6 +923,7 @@ async def _printer_status(db: AsyncSession, printer: Printer) -> PrinterStatus:
                 raw_data.get("mapping"),
                 active_extruder=state.active_extruder,
                 ams_extruder_map=nozzle_wiring(state),
+                tray_layout=tray_layout,
             )
             if state.state == "PAUSE"
             else None
@@ -923,6 +935,7 @@ async def _printer_status(db: AsyncSession, printer: Printer) -> PrinterStatus:
                 raw_data.get("mapping"),
                 active_extruder=state.active_extruder,
                 ams_extruder_map=nozzle_wiring(state),
+                tray_layout=tray_layout,
             )
             if state.state == "PAUSE"
             else None
@@ -2654,12 +2667,11 @@ async def start_calibration(
 
 
 def _slot_preset_key(ams_id: int, tray_id: int) -> int:
-    # Mirrors frontend getGlobalTrayId (amsHelpers.ts): AMS-HT (128-135) is keyed
-    # by ams_id since each unit has a single slot and shares its global ID with
-    # the unit itself. Regular AMS and external (255) use ams_id*4+tray_id.
-    if 128 <= ams_id <= 135:
-        return ams_id
-    return ams_id * 4 + tray_id
+    # Mirrors frontend getGlobalTrayId (amsHelpers.ts): the slot's global tray
+    # id; the external inputs keep their 255 * 4 + tray_id key.
+    if ams_id == EXTERNAL_AMS_ID:
+        return legacy_tray_index(ams_id, tray_id)
+    return studio_global_tray_id(ams_id, tray_id)
 
 
 @router.get("/{printer_id}/slot-presets")
@@ -3116,7 +3128,7 @@ async def configure_ams_slot(
 
             if ams_id == 255:
                 vt_tray = state.raw_data.get("vt_tray") or []
-                ext_id = tray_id + 254
+                ext_id = external_tray_id(tray_id)
                 for vt in vt_tray:
                     if isinstance(vt, dict) and int(vt.get("id", 254)) == ext_id:
                         current_tray_info_idx = vt.get("tray_info_idx", "")
@@ -3265,16 +3277,16 @@ async def configure_ams_slot(
     # hardcoded extruder_id=0 and nozzle_id=HS00.
     k_value_unsent = False
     if k_value > 0 and cali_idx < 0:
-        # Calculate global tray ID for extrusion_cali_set
+        # extrusion_cali_set's flat tray index; an AMS-HT counts from 128.
         if ams_id <= 3:
-            global_tray_id = ams_id * 4 + tray_id
-        elif ams_id >= 128 and ams_id <= 135:
-            global_tray_id = (ams_id - 128) * 4 + tray_id
+            cali_tray_id = legacy_tray_index(ams_id, tray_id)
+        elif is_ams_ht_id(ams_id):
+            cali_tray_id = legacy_tray_index(ams_id - AMS_HT_IDS.start, tray_id)
         else:
-            global_tray_id = tray_id
+            cali_tray_id = tray_id
 
         k_value_unsent = not client.extrusion_cali_set(
-            tray_id=global_tray_id,
+            tray_id=cali_tray_id,
             k_value=k_value,
             nozzle_diameter=nozzle_diameter,
             nozzle_temp=nozzle_temp_max,
@@ -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
 # a2l-am-unit-16. An AMS-HT holds one tray, numbered by its unit id 128-135, as
-# the printer's tray_now reports it. All are valid alongside the regular 0-15.
+# the printer's tray_now reports it. 28-36 are AMS 3H slots, valid only on a
+# printer that has one. All are valid alongside the regular 0-15.
 _LOAD_TRAY_ID_ERROR = (
-    "tray_id must be 0..15 (AMS slot), 24..27 (A2L AMS-Lite), 128..135 (AMS-HT), 254 (external / Ext-L), or 255 (Ext-R)"
+    "tray_id must be 0..15 (AMS slot), 24..27 (A2L AMS-Lite), 28..36 (H3C AMS 3H), 128..135 (AMS-HT), "
+    "254 (external / Ext-L), or 255 (Ext-R)"
 )
+_AMS3H_PRINTER = TrayLayout.ams3h()
 
 
 def _is_valid_load_tray_id(tray_id: int) -> bool:
     """Whether ``tray_id`` names a slot the load/unload commands can address."""
-    return tray_id in range(16) or tray_id in range(24, 28) or tray_id in range(128, 136) or tray_id in (254, 255)
+    return (
+        tray_id in range(16)
+        or tray_id in range(24, 28)
+        or _AMS3H_PRINTER.rack_slot(tray_id) is not None
+        or is_ams_ht_id(tray_id)
+        or tray_id in (254, 255)
+    )
+
+
+def _client_tray_layout(client: object) -> TrayLayout:
+    """The connected printer's tray numbering; empty for a client that offers none."""
+    layout = getattr(client, "tray_layout", None)
+    return layout if isinstance(layout, TrayLayout) else TrayLayout()
+
+
+def _refuse_foreign_ams3h_tray(tray_id: int | None, layout: TrayLayout) -> None:
+    """An AMS 3H slot id names nothing on a printer without that AMS 3H."""
+    if tray_id is not None and _AMS3H_PRINTER.rack_slot(tray_id) is not None and layout.rack_slot(tray_id) is None:
+        raise HTTPException(400, _LOAD_TRAY_ID_ERROR)
 
 
 @router.post("/{printer_id}/ams/load")
@@ -4752,6 +4785,7 @@ async def ams_load(
 
     Tray ID encoding (matches Bambu firmware convention):
     - 0..15: AMS slot, computed as ams_id * 4 + slot_id
+    - 28..36: the H3C's AMS 3H, 28 + slot (its slot feeds its own hotend)
     - 128..135: AMS-HT, the unit id (one tray per unit)
     - 254: external spool (single-external printers, or Ext-L on dual-nozzle H2D)
     - 255: Ext-R on dual-nozzle H2D
@@ -4768,18 +4802,33 @@ async def ams_load(
     if not client:
         raise HTTPException(400, "Printer not connected")
 
+    # An AMS 3H slot has nothing to feed until the AMS has pulled its filament
+    # in at the inlet; Studio offers no Load before that.
+    layout = _client_tray_layout(client)
+    _refuse_foreign_ams3h_tray(tray_id, layout)
+    rack_slot = layout.rack_slot(tray_id)
+    if rack_slot is not None and not client.rack_tray_has_filament(tray_id):
+        raise HTTPException(
+            409,
+            f"AMS 3H slot {rack_slot[1] + 1} has no filament. Push the filament into the slot's inlet "
+            "until the AMS pulls it in, then load it.",
+        )
+
     success = client.ams_load_filament(tray_id, extruder_id=extruder_id)
     if not success:
         raise HTTPException(500, "Failed to send load command")
 
-    if tray_id == 254:
+    if rack_slot is not None:
+        target = f"AMS 3H slot {rack_slot[1] + 1}"
+    elif tray_id == 254:
         target = "external spool"
     elif tray_id == 255:
         target = "Ext-R"
     elif tray_id >= 128:
         target = f"AMS HT-{chr(ord('A') + tray_id - 128)}"
     else:
-        target = f"AMS {tray_id // 4} slot {tray_id % 4 + 1}"
+        unit, slot = layout.slot_key(tray_id)
+        target = f"AMS {unit} slot {slot + 1}"
     return {"success": True, "message": f"Loading filament from {target}"}
 
 
@@ -4811,6 +4860,7 @@ async def ams_unload(
     client = printer_manager.get_client(printer_id)
     if not client:
         raise HTTPException(400, "Printer not connected")
+    _refuse_foreign_ams3h_tray(tray_id, _client_tray_layout(client))
 
     success = client.ams_unload_filament(tray_id)
     if not success:

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

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

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

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

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

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

+ 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:
-    """Widen ck_ams_id_range on spoolman_slot_assignments to admit AMS-HT (#1274).
+    """Bring spoolman_slot_assignments' CHECKs up to the model's (AMS-HT #1274, the H3C's AMS 3H).
 
-    Old formula: (ams_id >= 0 AND ams_id <= 7) OR ams_id = 255
-    New formula: (ams_id >= 0 AND ams_id <= 7) OR (ams_id >= 128 AND ams_id <= 191) OR ams_id = 255
+    ck_ams_id_range, oldest formula: (ams_id >= 0 AND ams_id <= 7) OR ams_id = 255
+    #1274 added AMS-HT:  ... OR (ams_id >= 128 AND ams_id <= 191) OR ams_id = 255
+    Now also the AMS 3H: ... OR ams_id = 32 OR ...
+    ck_tray_id_range:    tray_id 0-3, and 4-8 for ams_id 32 / 255 (models.spoolman_slot_assignment)
 
-    The H2C/H2D AMS-HT reports ams_id 128+. The old constraint rejected every
-    AMS-HT slot link with `IntegrityError: CHECK constraint failed: ck_ams_id_range`.
+    The H2C/H2D AMS-HT reports ams_id 128+. The H3C's AMS 3H is unit 32 with
+    nine slots, and its per-hotend external inputs are stored as (255, 2-8).
+    The old constraints rejected every such slot link with
+    `IntegrityError: CHECK constraint failed`. A table whose tray check is a
+    plain ``tray_id <= 8`` gets the final form too, without any row on slot 4-8
+    of another unit (no AMS has such a slot).
 
     PostgreSQL: DROP CONSTRAINT IF EXISTS + ADD new formula via _safe_execute.
-    SQLite: table recreation when the old (narrower) formula is detected in
-    sqlite_master. Fresh installs already have the widened constraint from
-    the CREATE TABLE migration above.
+    SQLite: table recreation when an older formula is detected in
+    sqlite_master. Fresh installs get the model's constraints from create_all.
     """
     from sqlalchemy import text
 
-    _NEW_FORMULA = "(ams_id >= 0 AND ams_id <= 7) OR (ams_id >= 128 AND ams_id <= 191) OR ams_id = 255"
-    _CONSTRAINT_NAME = "ck_ams_id_range"
+    from backend.app.models.spoolman_slot_assignment import AMS_ID_CHECK, TRAY_ID_CHECK
 
     if not is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE spoolman_slot_assignments DROP CONSTRAINT IF EXISTS ck_ams_id_range")
         await _safe_execute(
-            conn,
-            f"ALTER TABLE spoolman_slot_assignments DROP CONSTRAINT IF EXISTS {_CONSTRAINT_NAME}",
+            conn, f"ALTER TABLE spoolman_slot_assignments ADD CONSTRAINT ck_ams_id_range CHECK ({AMS_ID_CHECK})"
+        )
+        await _safe_execute(conn, "ALTER TABLE spoolman_slot_assignments DROP CONSTRAINT IF EXISTS ck_tray_id_range")
+        dropped = await conn.execute(
+            text(f"DELETE FROM spoolman_slot_assignments WHERE NOT ({TRAY_ID_CHECK})")  # nosec B608 -- the model's constant CHECK text
         )
+        if dropped.rowcount:
+            logger.warning("Dropped %d Spoolman slot assignment(s) naming a slot no AMS has", dropped.rowcount)
         await _safe_execute(
-            conn,
-            f"ALTER TABLE spoolman_slot_assignments ADD CONSTRAINT {_CONSTRAINT_NAME} CHECK ({_NEW_FORMULA})",
+            conn, f"ALTER TABLE spoolman_slot_assignments ADD CONSTRAINT ck_tray_id_range CHECK ({TRAY_ID_CHECK})"
         )
         return
 
@@ -1714,8 +1723,8 @@ async def _migrate_widen_spoolman_slot_ams_id_range(conn) -> None:
     if not row:
         return
     sql = row[0] or ""
-    # Already widened by an earlier run or by the fresh-install CREATE TABLE above.
-    if "ams_id >= 128" in sql:
+    # Already in the final form (a previous start, or create_all on a fresh install).
+    if "ams_id = 32" in sql and "ams_id IN (32, 255)" in sql:
         return
     # Pre-migration table without any CHECK constraint at all → leave alone;
     # the app-level validation handles correctness and we don't risk a
@@ -1731,29 +1740,38 @@ async def _migrate_widen_spoolman_slot_ams_id_range(conn) -> None:
                     "CREATE TABLE spoolman_slot_assignments_v2 ("
                     "id INTEGER PRIMARY KEY AUTOINCREMENT, "
                     "printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE, "
-                    f"ams_id INTEGER NOT NULL CHECK ({_NEW_FORMULA}), "
-                    "tray_id INTEGER NOT NULL CHECK (tray_id >= 0 AND tray_id <= 3), "
+                    "ams_id INTEGER NOT NULL, "
+                    "tray_id INTEGER NOT NULL, "
                     "spoolman_spool_id INTEGER NOT NULL, "
                     "assigned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "
-                    "CONSTRAINT uq_slot_assignment UNIQUE(printer_id, ams_id, tray_id)"
+                    "CONSTRAINT uq_slot_assignment UNIQUE(printer_id, ams_id, tray_id), "
+                    f"CONSTRAINT ck_ams_id_range CHECK ({AMS_ID_CHECK}), "
+                    f"CONSTRAINT ck_tray_id_range CHECK ({TRAY_ID_CHECK})"
                     ")"
                 )
             )
             await conn.execute(
                 text(
-                    "INSERT INTO spoolman_slot_assignments_v2 "
+                    "INSERT INTO spoolman_slot_assignments_v2 "  # nosec B608 -- the model's constant CHECK text
                     "(id, printer_id, ams_id, tray_id, spoolman_spool_id, assigned_at) "
                     "SELECT id, printer_id, ams_id, tray_id, spoolman_spool_id, assigned_at "
-                    "FROM spoolman_slot_assignments"
+                    f"FROM spoolman_slot_assignments WHERE {TRAY_ID_CHECK}"
                 )
             )
             original = (await conn.execute(text("SELECT count(*) FROM spoolman_slot_assignments"))).scalar_one()
+            kept = (
+                await conn.execute(
+                    text(f"SELECT count(*) FROM spoolman_slot_assignments WHERE {TRAY_ID_CHECK}")  # nosec B608 -- the model's constant CHECK text
+                )
+            ).scalar_one()
             copied = (await conn.execute(text("SELECT count(*) FROM spoolman_slot_assignments_v2"))).scalar_one()
-            if copied != original:
+            if copied != kept:
                 raise RuntimeError(
                     f"spoolman_slot_assignments migration: row count mismatch after copy "
-                    f"({original} in source, {copied} in copy)"
+                    f"({kept} to keep in source, {copied} in copy)"
                 )
+            if original != kept:
+                logger.warning("Dropped %d Spoolman slot assignment(s) naming a slot no AMS has", original - kept)
             await conn.execute(text("DROP TABLE spoolman_slot_assignments"))
             await conn.execute(text("ALTER TABLE spoolman_slot_assignments_v2 RENAME TO spoolman_slot_assignments"))
             # The index sits on the renamed table; recreate it idempotently
@@ -4692,18 +4710,21 @@ async def _run_migrations(conn):
     # Migration: Create spoolman_slot_assignments table for local AMS-slot→Spoolman-spool mapping.
     # Replaces the pattern of writing spool.location in Spoolman (which polluted the
     # user-editable storage_location field in the UI).
-    # ck_ams_id_range formula was widened in #1274 to admit AMS-HT (ams_id 128-191).
+    # ck_ams_id_range formula was widened in #1274 to admit AMS-HT (ams_id 128-191),
+    # and for the H3C's AMS 3H (as models.spoolman_slot_assignment).
     await _safe_execute(
         conn,
         """
         CREATE TABLE IF NOT EXISTS spoolman_slot_assignments (
             id INTEGER PRIMARY KEY AUTOINCREMENT,
             printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE,
-            ams_id INTEGER NOT NULL CHECK ((ams_id >= 0 AND ams_id <= 7) OR (ams_id >= 128 AND ams_id <= 191) OR ams_id = 255),
-            tray_id INTEGER NOT NULL CHECK (tray_id >= 0 AND tray_id <= 3),
+            ams_id INTEGER NOT NULL,
+            tray_id INTEGER NOT NULL,
             spoolman_spool_id INTEGER NOT NULL,
             assigned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
-            CONSTRAINT uq_slot_assignment UNIQUE(printer_id, ams_id, tray_id)
+            CONSTRAINT uq_slot_assignment UNIQUE(printer_id, ams_id, tray_id),
+            CONSTRAINT ck_ams_id_range CHECK ((ams_id >= 0 AND ams_id <= 7) OR ams_id = 32 OR (ams_id >= 128 AND ams_id <= 191) OR ams_id = 255),
+            CONSTRAINT ck_tray_id_range CHECK (tray_id >= 0 AND (tray_id <= 3 OR (ams_id IN (32, 255) AND tray_id <= 8)))
         )
         """
         if is_sqlite()
@@ -4711,11 +4732,13 @@ async def _run_migrations(conn):
         CREATE TABLE IF NOT EXISTS spoolman_slot_assignments (
             id SERIAL PRIMARY KEY,
             printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE,
-            ams_id INTEGER NOT NULL CHECK ((ams_id >= 0 AND ams_id <= 7) OR (ams_id >= 128 AND ams_id <= 191) OR ams_id = 255),
-            tray_id INTEGER NOT NULL CHECK (tray_id >= 0 AND tray_id <= 3),
+            ams_id INTEGER NOT NULL,
+            tray_id INTEGER NOT NULL,
             spoolman_spool_id INTEGER NOT NULL,
             assigned_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-            CONSTRAINT uq_slot_assignment UNIQUE(printer_id, ams_id, tray_id)
+            CONSTRAINT uq_slot_assignment UNIQUE(printer_id, ams_id, tray_id),
+            CONSTRAINT ck_ams_id_range CHECK ((ams_id >= 0 AND ams_id <= 7) OR ams_id = 32 OR (ams_id >= 128 AND ams_id <= 191) OR ams_id = 255),
+            CONSTRAINT ck_tray_id_range CHECK (tray_id >= 0 AND (tray_id <= 3 OR (ams_id IN (32, 255) AND tray_id <= 8)))
         )
         """,
     )
@@ -4725,8 +4748,8 @@ async def _run_migrations(conn):
     )
 
     # Migration: widen ck_ams_id_range on spoolman_slot_assignments to allow
-    # AMS-HT ids (128-191). Existing installs created before #1274 carry the
-    # stale formula which rejects every AMS-HT slot link with a CHECK violation.
+    # AMS-HT ids (128-191) and the H3C's AMS 3H (32, nine slots). Existing
+    # installs carry a stale formula which rejects such slot links.
     await _migrate_widen_spoolman_slot_ams_id_range(conn)
 
     # Migration: Create spoolman_k_profile table for K-value calibration profiles linked to Spoolman spools.
@@ -6040,6 +6063,7 @@ async def _run_migrations(conn):
     await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN plate_estimates JSON")
 
     await _backfill_archive_run_photos(conn)
+    await _migrate_h3c_queue_tray_ids(conn)
 
     await _migrate_auth_datetimes_to_timestamp(conn)
     await _migrate_add_missing_set_null_fks(conn)
@@ -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"})
 
 
+async def _migrate_h3c_queue_tray_ids(conn) -> None:
+    """Renumber the AMS 3H slots of queued H3C jobs from 128 + k to Studio's 28 + k.
+
+    Bambuddy without H3C support numbered the AMS 3H (unit 32) as
+    ``32 * 4 + k``, the AMS-HT range, so an H3C job queued then carries 128-136
+    where the slot is now 28-36. Only jobs for an H3C (its printer, or
+    target_model H3C) are touched; the H3C has no AMS-HT, so 128-136 there can
+    only mean that. Once only, gated by a settings flag, so a mapping written
+    afterwards is never rewritten.
+    """
+    import json
+
+    from sqlalchemy import text
+
+    from backend.app.utils.tray_ids import AMS3H_UNIT_ID, RACK_SLOT_COUNT, TrayLayout, legacy_tray_index
+
+    old_base = legacy_tray_index(AMS3H_UNIT_ID, 0)  # 128
+    ams3h = TrayLayout.ams3h()
+    flag = "_migrate_h3c_queue_tray_ids_done"
+    async with conn.begin_nested():
+        already = (
+            await conn.execute(text('SELECT value FROM settings WHERE "key" = :k'), {"k": flag})
+        ).scalar_one_or_none()
+        if already is not None:
+            return
+        targets = (
+            (
+                "print_queue",
+                "q.ams_mapping IS NOT NULL AND (UPPER(q.target_model) IN ('H3C', 'N10') "
+                "OR q.printer_id IN (SELECT id FROM printers WHERE UPPER(model) IN ('H3C', 'N10')))",
+            ),
+            ("print_queue_variants", "q.ams_mapping IS NOT NULL AND UPPER(q.target_model) IN ('H3C', 'N10')"),
+        )
+        renumbered = 0
+        for table, where in targets:
+            query = f"SELECT q.id, q.ams_mapping FROM {table} q WHERE {where}"  # nosec B608 -- table and filter from the fixed targets above
+            rows = (await conn.execute(text(query))).all()
+            for row_id, raw in rows:
+                try:
+                    mapping = json.loads(raw)
+                except (TypeError, ValueError):
+                    continue
+                if not isinstance(mapping, list):
+                    continue
+                new = [
+                    ams3h.global_tray_id(AMS3H_UNIT_ID, v - old_base)
+                    if type(v) is int and old_base <= v < old_base + RACK_SLOT_COUNT
+                    else v
+                    for v in mapping
+                ]
+                if new != mapping:
+                    await conn.execute(
+                        text(f"UPDATE {table} SET ams_mapping = :m WHERE id = :id"),  # nosec B608 -- table from the fixed targets above
+                        {"m": json.dumps(new), "id": row_id},
+                    )
+                    renumbered += 1
+        if renumbered:
+            logger.info("Renumbered the AMS 3H slots of %d queued H3C job(s) to Bambu Studio's 28-36", renumbered)
+        await conn.execute(text('INSERT INTO settings ("key", value) VALUES (:k, :v)'), {"k": flag, "v": "true"})
+
+
 async def _backfill_archive_run_photos(conn) -> None:
     """Put back on its archive a finish photo only its run kept.
 

+ 23 - 23
backend/app/main.py

@@ -159,6 +159,7 @@ from backend.app.services.printer_manager import (
     parse_plate_id,
     printer_manager,
     printer_state_to_dict,
+    printer_tray_layout,
     resolve_plate_id,
 )
 from backend.app.services.slot_kprofile import find_slot_kprofile_for_extruder
@@ -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.local_time import to_naive_utc, utcnow_naive
 from backend.app.utils.print_jobs import is_internal_printer_job
+from backend.app.utils.tray_ids import AmsType, TrayLayout, external_tray_id, unit_ams_type, unit_label
 
 
 # =============================================================================
@@ -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
 
     # Map global tray id → (type, color), addressed as the print's ams_mapping
-    # is: unit * 4 + tray, or the bare unit id for an AMS-HT (from 128).
+    # is (utils.tray_ids).
+    layout = TrayLayout.from_units(ams_units)
     loaded: dict[int, tuple[str, str]] = {}
     for unit in ams_units:
         if not isinstance(unit, dict):
@@ -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 ""
             if not ttype:
                 continue  # Empty / unloaded slot.
-            loaded[_global_tray_id(unit_id, tray_id)] = (ttype, tcolor)
+            loaded[_global_tray_id(unit_id, tray_id, layout)] = (ttype, tcolor)
 
     if not loaded:
         return result
@@ -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)
     if status is None:
         return False
-    if ams_id == 255:
-        global_id = 254 + tray_id
-    elif ams_id >= 128:
-        global_id = ams_id
-    else:
-        global_id = ams_id * 4 + tray_id
+    global_id = printer_tray_layout(printer_id, printer_manager).tray_from_slot_key(ams_id, tray_id)
     if getattr(status, "tray_now", None) == global_id:
         return True
     slots = getattr(status, "extruder_slots", None) or {}
@@ -2796,7 +2794,7 @@ async def _unlink_stale_assignments(printer_id: int, ams_data: list, printing_no
                 if assignment.ams_id == 255:
                     ps = printer_manager.get_status(printer_id)
                     vt_tray_raw = ps.raw_data.get("vt_tray", []) if ps else []
-                    ext_id = assignment.tray_id + 254  # 0→254, 1→255
+                    ext_id = external_tray_id(assignment.tray_id)  # 0→254, 1→255, rack 2-8→247-253
                     current_tray = None
                     for vt in vt_tray_raw:
                         if isinstance(vt, dict) and int(vt.get("id", 254)) == ext_id:
@@ -8234,8 +8232,16 @@ async def reconcile_stale_active_prints(printer_id: int) -> int:
         if outcome != "aborted":
             # The printer's progress and layer are this job's, so a partial
             # run is charged what it printed rather than nothing.
-            if state.progress:
-                payload["last_progress"] = state.progress
+            from backend.app.utils.printer_models import usage_progress
+
+            run_progress = usage_progress(
+                printer_manager.get_model(printer_id),
+                state.progress,
+                getattr(state, "layer_num", None),
+                getattr(state, "total_layers", None),
+            )
+            if run_progress:
+                payload["last_progress"] = run_progress
             if state.layer_num:
                 payload["last_layer_num"] = state.layer_num
         if outcome == "failed":
@@ -11117,11 +11123,9 @@ async def record_ams_history():
                         recorded_count += 1
 
                         # Generate AMS label and determine if it's AMS-HT (A, B, C, D or HT-A for AMS-Lite/Hub)
-                        is_ams_ht = ams_id >= 128
-                        if is_ams_ht:
-                            ams_label = f"HT-{chr(65 + (ams_id - 128))}"
-                        else:
-                            ams_label = f"AMS-{chr(65 + ams_id)}"
+                        ams_type = unit_ams_type(ams_id, ams_data.get("info"))
+                        is_ams_ht = ams_type == AmsType.N3S
+                        ams_label = unit_label(ams_id, ams_type)
 
                         # Skip alarm dispatch for empty AMS units — humidity /
                         # temperature readings are ambient with no filament to
@@ -12231,13 +12235,9 @@ async def lifespan(app: FastAPI):
                 _slot_label_from_global_tray,
             )
 
-            if ams_id == 255:
-                global_id = 254 + tray_id
-            elif ams_id >= 128:
-                global_id = ams_id
-            else:
-                global_id = ams_id * 4 + tray_id
-            slot_label = _slot_label_from_global_tray(global_id)
+            layout = printer_tray_layout(printer_id, printer_manager)
+            global_id = layout.tray_from_slot_key(ams_id, tray_id)
+            slot_label = _slot_label_from_global_tray(global_id, layout)
 
             printer_info = printer_manager.get_printer(printer_id)
             printer_name = printer_info.name if printer_info else f"Printer {printer_id}"

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

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

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

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

+ 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_target_temp: int | None = None  # Active-cycle target °C (Bambu doesn't echo this)
     dry_filament: str | None = None  # Active-cycle filament name we sent
-    module_type: str = ""  # "ams", "ams_f1", "n3f", "n3s"
+    module_type: str = ""  # "ams", "ams_f1", "n3f", "n3s", "n10a" (the H3C's AMS 3H)
+    ams_type: int | None = None  # Studio's AMS type (utils.tray_ids.AmsType); decides the global tray ids
+    # The AMS 3H's dryer: its drying zone's two slots and readings. Empty elsewhere.
+    dryer_slots: list[int] = []
+    dryer_humidity: int | None = None
+    dryer_temp: float | None = None
 
 
 class NozzleInfoResponse(BaseModel):
@@ -351,6 +356,7 @@ class NozzleRackSlot(BaseModel):
     filament_color: str = ""  # RGBA hex ("00000000" = no filament)
     filament_id: str = ""  # Bambu filament ID
     filament_type: str = ""  # Material type (e.g. "PLA", "PETG")
+    addr_id: int | None = None  # H3C: home rack position 0-8 (the mounted hotend reports id 0)
 
 
 class AmsLabelBody(BaseModel):

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

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

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

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

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

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

+ 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_humidity import ams_humidity_percent
 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__)
 
@@ -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)
 #   "n3f/<id>"     – AMS 2 Pro (H2D Pro and similar)
 #   "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
 # 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 (
         A2L_LITE_PHYSICAL_AMS_ID,
         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.
 
     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
     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
-    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 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).
 
     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
     ``{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
-    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.
     """
     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):
         return ams_mapping
     resolved = list(ams_mapping)
+    layout = layout or TrayLayout()
     for i, (flat, detail) in enumerate(zip(ams_mapping, ams_mapping2, strict=True)):
         if not isinstance(detail, dict):
             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):
             resolved[i] = A2L_LITE_GLOBAL_BASE + slot_id
             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
-        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
 
 
@@ -376,9 +499,10 @@ def apply_tray_exist_bits(
         ams_id = normalize_am_unit_id(ams_id)
         # 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.
-        # 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
         for tray in ams_unit.get("tray", []):
             if not isinstance(tray, dict):
@@ -392,7 +516,9 @@ def apply_tray_exist_bits(
                 continue
             if not isinstance(tray_id, int):
                 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
             if annotate_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.
         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
         # when we have never seen a CONNACK failure since the last success.
         # 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_mapping2"),
             self._is_dual_nozzle or is_dual_nozzle_model(self.model),
+            self.tray_layout,
         )
 
     @staticmethod
@@ -2593,6 +2725,8 @@ class BambuMQTTClient:
                     # 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.
                     # 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":
                         vir_slot[0]["id"] = "254"
                     self.state.raw_data["vt_tray"] = vir_slot
@@ -2847,6 +2981,8 @@ class BambuMQTTClient:
             if not isinstance(module, dict):
                 continue
             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):
                 continue
             try:
@@ -3181,17 +3317,7 @@ class BambuMQTTClient:
         if not isinstance(mapping_raw, list) or not mapping_raw:
             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:
             candidates = {tray: unit for tray, unit in candidates.items() if unit in units}
         if len(candidates) == 1:
@@ -3259,6 +3385,62 @@ class BambuMQTTClient:
                     )
                 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:
         """Read the Filament Track Switch block out of a print payload — #1162.
 
@@ -3323,37 +3505,15 @@ class BambuMQTTClient:
                 continue
             if ext_id <= 1:
                 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
 
     def _parse_extruder_slots(self, data: dict) -> None:
@@ -3409,6 +3569,7 @@ class BambuMQTTClient:
             if "ams" in ams_data:
                 ams_list = ams_data["ams"]
                 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
             non_list_fields = {k: v for k, v in ams_data.items() if k != "ams"}
             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)
                 # — disambiguated below using MQTT mapping field
                 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
                     pending_target = self.state.pending_tray_target
                     pending_at = self.state.pending_tray_target_at
@@ -3509,7 +3679,7 @@ class BambuMQTTClient:
                     if pending_target is not None:
                         # Same slot rule as the snow branch below: an AMS-HT
                         # (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:
                             # Slot matches our pending target - use the full global ID
                             logger.debug(
@@ -3541,7 +3711,7 @@ class BambuMQTTClient:
                             if snow_tray is None
                             else snow_tray % 4
                             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
                         # names the tray only while it agrees with the slot.
@@ -3600,26 +3770,20 @@ class BambuMQTTClient:
                             elif len(ams_on_extruder) == 1:
                                 # Single AMS on this extruder - unambiguous
                                 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(
                                     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:
                                 # Multiple AMS on this extruder - keep current if valid, else try to narrow down
                                 current_tray = self.state.tray_now
                                 # 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:
                                     current_ams = -1
                                     current_slot = -1
@@ -3637,7 +3801,7 @@ class BambuMQTTClient:
                                         candidates = ams_on_extruder
                                     if len(candidates) == 1:
                                         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(
                                             f"[{self.serial_number}] H2D tray_now: multiple AMS {ams_on_extruder}, "
                                             f"narrowed to AMS {cand} -> global ID {resolved}"
@@ -3740,15 +3904,8 @@ class BambuMQTTClient:
                     self.state.tray_now = parsed_tray_now
 
                 # 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
-                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
                     # print-lifecycle flags (`_was_running` set on first RUNNING /
                     # new print, `_completion_triggered` set when on_print_complete
@@ -3787,6 +3944,7 @@ class BambuMQTTClient:
         elif isinstance(ams_data, list):
             ams_list = ams_data
             self._normalize_a2l_am_units(ams_list)
+            apply_rack_ams_zone_fields(ams_list, dict(self.tray_layout.units))
         else:
             logger.warning("[%s] Unexpected AMS data format: %s", self.serial_number, type(ams_data))
             return
@@ -3984,9 +4142,14 @@ class BambuMQTTClient:
         inlet_moves: list[tuple[int, str]] = []
         ams_extruder_map = dict(self.state.ams_extruder_map) if self.state.ams_extruder_map else {}
         ams_switch_inlet = dict(self.state.ams_switch_inlet) if self.state.ams_switch_inlet else {}
+        # 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:
             ams_id = ams_unit.get("id")
             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:
                 try:
                     # info is a hex-encoded string in MQTT JSON (e.g. "10001003")
@@ -4332,7 +4495,7 @@ class BambuMQTTClient:
         """
         raw = self.state.raw_data or {}
         if ams_id == 255:
-            want_ext = 254 + tray_id
+            want_ext = external_tray_id(tray_id)
             for vt in raw.get("vt_tray", []) or []:
                 if isinstance(vt, dict) and str(vt.get("id")) == str(want_ext):
                     return vt
@@ -5705,6 +5868,8 @@ class BambuMQTTClient:
                                 # H2C uses "fila_id", H2D uses "filament_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", "")),
+                                # 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)
                         ],
@@ -5917,12 +6082,7 @@ class BambuMQTTClient:
             # Seeded with the assigned spools by usage_tracker.on_print_start.
             self.state.spool_change_log = []
             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))
             # Initialize timelapse tracking based on current state
             # 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
             # Include HMS errors for failure reason detection
             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(
                 {
                     "status": status,
@@ -6063,7 +6228,7 @@ class BambuMQTTClient:
                     "hms_errors": hms_errors_data,
                     "ams_mapping": self._captured_ams_mapping,
                     # 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,
                 }
             )
@@ -6431,14 +6596,26 @@ class BambuMQTTClient:
             # Passing raw 254/255 in the flat array causes H2D firmware to fail
             # with 0700_8012 "Failed to get AMS mapping table".
             flat_ams_mapping = []
+            layout = self.tray_layout
             if ams_mapping is not None:
                 for tray_id in ams_mapping:
                     # Ensure tray_id is an integer (may be string from JSON)
                     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:
                         # Unmapped filament slot
                         flat_ams_mapping.append(-1)
                         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:
                         # External/virtual spool. BambuStudio convention:
                         #   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)
                         flat_ams_mapping.append(tray_id)
                         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
                         # LOCAL slot 0-3 and ams_mapping2 carries {ams_id:16, slot_id:0-3}
                         # — 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})
                     else:
                         # 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)
                         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
             #     never gets force-enabled by (a) either.
             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
                 # filament list that it prints only from the spool holder: a
                 # 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)
             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(
         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
         # 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
+        ams3h = self._rack_ams_type(ams_id) == AmsType.ZMULTI_A
         command = {
             "print": {
                 "sequence_id": str(self._sequence_id),
@@ -7062,6 +7246,14 @@ class BambuMQTTClient:
                 "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
         # what we sent — needed to diagnose silent rejections (#1447) where
         # the printer ACKs the command but never starts/stops drying.
@@ -7804,6 +7996,16 @@ class BambuMQTTClient:
         Returns:
             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
         result = self.send_gcode(f"M141 S{target}")
         # Track chamber target locally (MQTT reports encoded values that need filtering)
@@ -7953,6 +8155,87 @@ class BambuMQTTClient:
         )
         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:
         """Load filament from a specific AMS tray.
 
@@ -7978,6 +8261,17 @@ class BambuMQTTClient:
         if not self._client or not self.state.connected:
             logger.warning("[%s] Cannot load filament: not connected", self.serial_number)
             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:
         #   - AMS slots (0..15, A2L 24..27, AMS-HT 128..135): see
@@ -8005,7 +8299,7 @@ class BambuMQTTClient:
         else:
             # A regular AMS, the A2L AMS-Lite (physical unit 16) or an AMS-HT
             # (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
             tar_temp = -1
 
@@ -8058,6 +8352,13 @@ class BambuMQTTClient:
         if not self._client or not self.state.connected:
             logger.warning("[%s] Cannot unload filament: not connected", self.serial_number)
             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
         tray_now = self.state.tray_now
@@ -8069,7 +8370,7 @@ class BambuMQTTClient:
             ams_id = 255  # No filament or external spool
         else:
             # 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
         # 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
         # ams*4+slot, so the local-slot arithmetic below cannot describe them.
         if tray_id is not None and tray_id not in (254, 255) and self._is_dual_nozzle and self.state.extruder_slots:
-            local_slot = change_filament_wire_ids(tray_id)[1]
+            local_slot = change_filament_wire_ids(tray_id, self.tray_layout)[1]
             holder = next(
                 (ext for ext, slot in self.state.extruder_slots.items() if slot.holds(ams_id, local_slot)),
                 None,
@@ -8194,11 +8495,13 @@ class BambuMQTTClient:
         tray_now = self.state.tray_now
         if tray_now != 255:
             # Decode which tray is loaded for the message
+            layout = self.tray_layout
             if tray_now == 254:
                 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:
-                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}"
             else:
                 loaded_tray = f"tray {tray_now}"
@@ -8234,10 +8537,10 @@ class BambuMQTTClient:
             "[%s] ams_get_rfid refused (%s), falling back to M620 R%s",
             self.serial_number,
             refused,
-            wire_ams_id * 4 + wire_slot_id,
+            legacy_tray_index(wire_ams_id, wire_slot_id),
         )
         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:
             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
                 # covered by the X1C capture — left at `mqtt_tray_id = 0` until a
                 # 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
             else:
                 # 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}).
             mqtt_ams_id, slot_id, _ = _a2l
             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:
             mqtt_ams_id = ams_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 []
             if len(vt_tray) > 1:
                 # 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
             else:
                 # 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).
             mqtt_ams_id, slot_id, _ = _a2l
             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:
             mqtt_ams_id = ams_id
             mqtt_tray_id = tray_id
@@ -8482,23 +8793,27 @@ class BambuMQTTClient:
             if len(vt_tray) > 1:
                 # 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
-                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:
                 # Single external slot (X1C, P1S, A1): global tray_id=254
                 mqtt_ams_id = 254
                 mqtt_tray_id = 254
             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:
             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
         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
             # tray_id this command wants (physical 16*4+slot) is extrapolated (no
             # A2L cali_sel capture yet) — see a2l_lite_wire_ids.
             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_tray_id = tray_id
             slot_id = 0
@@ -8571,7 +8886,10 @@ class BambuMQTTClient:
         # physical global (extrapolated 64-67; see a2l_lite_wire_ids). ams_id
         # stays 0 (hardcoded, as for every other unit here).
         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]
 
         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:
     """Check if printer model supports RTSP camera streaming.
 
-    RTSP supported: X1, X1C, X1E, X2D, H2C, H2D, H2DPRO, H2S, P2S
+    RTSP supported: X1, X1C, X1E, X2D, H2C, H2D, H2DPRO, H2S, H3C, P2S
     Chamber image only: A1, A1MINI, P1P, P1S
 
     Note: Model can be either display name (e.g., "P2S") or internal code (e.g., "N7").
@@ -201,14 +201,15 @@ def supports_rtsp(model: str | None) -> bool:
       - O1S: H2S
       - O1E, O2D: H2D Pro
       - N7: P2S
+      - N10: H3C
     """
     if model:
         model_upper = model.upper()
-        # Display names: X1, X1C, X1E, X2D, H2C, H2D, H2DPRO, H2S, P2S
-        if model_upper.startswith(("X1", "X2", "H2", "P2")):
+        # Display names: X1, X1C, X1E, X2D, H2C, H2D, H2DPRO, H2S, H3C, P2S
+        if model_upper.startswith(("X1", "X2", "H2", "H3", "P2")):
             return True
         # Internal codes for RTSP models
-        if model_upper in ("BL-P001", "C13", "N6", "O1D", "O1C", "O1C2", "O1S", "O1E", "O2D", "N7"):
+        if model_upper in ("BL-P001", "C13", "N6", "O1D", "O1C", "O1C2", "O1S", "O1E", "O2D", "N7", "N10"):
             return True
     # A1/P1 and unknown models use chamber image protocol
     return False
@@ -217,7 +218,7 @@ def supports_rtsp(model: str | None) -> bool:
 def get_camera_port(model: str | None) -> int:
     """Get the camera port based on printer model.
 
-    X1/X2/H2/P2 series use RTSP on port 322.
+    X1/X2/H2/H3/P2 series use RTSP on port 322.
     A1/P1 series use chamber image protocol on port 6000.
     """
     if supports_rtsp(model):

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

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

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

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

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

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

+ 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.print_confirmation import one_tap_url
 from backend.app.utils.notification_photos import save_notification_photo
+from backend.app.utils.tray_ids import EXTERNAL_AMS_ID, is_ams_ht_id, unit_label
 
 logger = logging.getLogger(__name__)
 
@@ -1929,10 +1930,10 @@ class NotificationService:
                     tray_id = u.get("tray_id", 0)
                     material = u.get("material", "Unknown") or "Unknown"
                     used = u.get("weight_used", 0)
-                    if ams_id >= 128:
+                    if ams_id == EXTERNAL_AMS_ID or is_ams_ht_id(ams_id):
                         slot_label = "Ext"
                     else:
-                        slot_label = f"AMS-{chr(65 + ams_id)} T{tray_id + 1}"
+                        slot_label = f"{unit_label(ams_id)} T{tray_id + 1}"
                     parts.append(f"{slot_label} {material}: {used:.1f}g")
                 variables["filament_details"] = " | ".join(parts)
             elif archive_data.get("filament_slots"):

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

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

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

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

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

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

+ 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.inventory_mode import spoolman_owns_assignments
 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."""
-    if ams_id in (254, 255):
-        return 254 + tray_id
-    if ams_id >= 128:
-        return ams_id
-    return ams_id * 4 + tray_id
+    if ams_id == EXTERNAL_DEPUTY_ID:
+        # Older records keep the left external spool as its own ams_id.
+        return EXTERNAL_DEPUTY_ID + tray_id
+    return (layout or TrayLayout()).tray_from_slot_key(ams_id, tray_id)
 
 
-def _slot_label_from_global_tray(global_tray_id: int) -> str:
+def _slot_label_from_global_tray(global_tray_id: int, layout: TrayLayout | None = None) -> str:
     """Return a human-readable slot label from a global tray ID."""
+    layout = layout or TrayLayout()
+    if layout.rack and layout.is_external(global_tray_id):
+        # 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:
         return "Ext-L"
     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.
     if 24 <= global_tray_id <= 27:
         return f"Lite-{(global_tray_id % 4) + 1}"
-    ams_id = global_tray_id // 4
-    tray_id = global_tray_id % 4
+    ams_id, tray_id = layout.slot_key(global_tray_id)
+    kind = layout.ams_type(ams_id)
+    if kind == AmsType.ZMULTI_A:
+        return f"3H-{tray_id + 1}"
+    if kind == AmsType.ZMULTI_B:
+        return f"B-{tray_id + 1}"
     return f"{chr(65 + ams_id)}{tray_id + 1}"
 
 
@@ -43,6 +57,7 @@ def _tray_profile_and_color_for_global_id(state: PrinterState | None, global_tra
 
     ams_raw = state.raw_data.get("ams", {})
     ams_units = ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
+    layout = TrayLayout.from_state(state)
 
     vt_trays = state.raw_data.get("vt_tray", [])
     if not isinstance(vt_trays, list):
@@ -67,8 +82,7 @@ def _tray_profile_and_color_for_global_id(state: PrinterState | None, global_tra
             if not isinstance(tray, dict):
                 continue
             tray_id = int(tray.get("id", -1))
-            candidate = ams_id if ams_id >= 128 else (ams_id * 4 + tray_id)
-            if candidate == global_tray_id:
+            if layout.global_tray_id(ams_id, tray_id) == global_tray_id:
                 profile = tray.get("tray_sub_brands") or tray.get("tray_type") or "Unknown"
                 color = tray.get("tray_color") or "Unknown"
                 return (profile, color)
@@ -76,37 +90,13 @@ def _tray_profile_and_color_for_global_id(state: PrinterState | None, global_tra
     return ("Unknown", "Unknown")
 
 
-def _decode_mqtt_mapping_to_global_trays(mapping_raw: object) -> list[int]:
+def _decode_mqtt_mapping_to_global_trays(mapping_raw: object, layout: TrayLayout | None = None) -> list[int]:
     """Decode printer MQTT mapping values into Bambuddy global tray IDs."""
     if not isinstance(mapping_raw, list) or not mapping_raw:
         return []
 
-    decoded: list[int] = []
-    for value in mapping_raw:
-        try:
-            if isinstance(value, int):
-                encoded = value
-            elif isinstance(value, str):
-                encoded = int(value, 10)
-            else:
-                continue
-        except ValueError:
-            continue
-
-        if encoded >= 65535:
-            continue
-
-        ams_hw_id = (encoded >> 8) & 0xFF
-        slot = encoded & 0xFF
-
-        if 0 <= ams_hw_id <= 3:
-            decoded.append(ams_hw_id * 4 + (slot & 0x03))
-        elif 128 <= ams_hw_id <= 135:
-            decoded.append(ams_hw_id)
-        elif ams_hw_id in (254, 255):
-            decoded.append(255 if slot == 255 else 254)
-
-    return decoded
+    decoded = (decode_wire_slot(value, layout) for value in mapping_raw)
+    return [tray for tray in decoded if tray is not None]
 
 
 async def notify_missing_spool_assignments_on_print_start(
@@ -120,7 +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 []
     )
     raw_mapping = data.get("raw_data", {}).get("mapping") if isinstance(data.get("raw_data"), dict) else None
-    decoded_values = _decode_mqtt_mapping_to_global_trays(raw_mapping)
+    layout = printer_tray_layout(printer_id, printer_manager)
+    decoded_values = _decode_mqtt_mapping_to_global_trays(raw_mapping, layout)
     mapping_values = explicit_values if explicit_values else decoded_values
 
     used_global_trays = {value for value in mapping_values if value >= 0}
@@ -146,7 +137,7 @@ async def notify_missing_spool_assignments_on_print_start(
             # precisely to catch that tray.
             table = SpoolmanSlotAssignment if await spoolman_owns_assignments(db) else SpoolAssignment
             rows = (await db.execute(table.__table__.select().where(table.printer_id == printer_id))).fetchall()
-            assigned_global_trays = {_global_tray_from_assignment(row.ams_id, row.tray_id) for row in rows}
+            assigned_global_trays = {_global_tray_from_assignment(row.ams_id, row.tray_id, layout) for row in rows}
 
             missing_global = sorted(used_global_trays - assigned_global_trays)
             if not missing_global:
@@ -165,12 +156,13 @@ async def _send_missing_assignment_notification(
 ) -> None:
     """Describe the unassigned trays and push them to the UI and the providers."""
     state = printer_manager.get_status(printer_id)
+    layout = TrayLayout.from_state(state)
     missing_slots = []
     for global_id in missing_global:
         profile, color = _tray_profile_and_color_for_global_id(state, global_id)
         missing_slots.append(
             {
-                "slot": _slot_label_from_global_tray(global_id),
+                "slot": _slot_label_from_global_tray(global_id, layout),
                 "profile": profile,
                 "color": color,
             }

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

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

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

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

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

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

@@ -24,6 +24,7 @@ PRINTER_MODEL_MAP = {
     "Bambu Lab H2S": "H2S",
     "Bambu Lab X2D": "X2D",
     "Bambu Lab A2L": "A2L",
+    "Bambu Lab H3C": "H3C",
 }
 
 # Map from printer_model_id (internal codes in slice_info.config) to short names
@@ -61,6 +62,10 @@ PRINTER_MODEL_ID_MAP = {
     "O1C": "H2C",
     "O1C2": "H2C",
     "O1S": "H2S",
+    # H3 series ("Bambu Lab H3C.json" model_id). One extruder with nine
+    # nozzles in Studio's profile, so not in DUAL_NOZZLE_MODELS or
+    # NOZZLE_RACK_MODELS: the H2C's rack ids and left/right split don't fit it.
+    "N10": "H3C",
 }
 
 
@@ -107,6 +112,7 @@ LINEAR_RAIL_MODELS = frozenset(
         "H2DPRO",
         "H2C",
         "H2S",
+        "H3C",
         # Internal codes
         "N1",  # A1 Mini
         "N2S",  # A1
@@ -120,6 +126,7 @@ LINEAR_RAIL_MODELS = frozenset(
         "O1C",  # H2C
         "O1C2",  # H2C (dual nozzle variant)
         "O1S",  # H2S
+        "N10",  # H3C
     ]
 )
 
@@ -374,6 +381,54 @@ def is_dual_nozzle_model(model: str | None) -> bool:
     return normalized in DUAL_NOZZLE_MODELS
 
 
+# Chamber heater runs only in the airduct's heating mode (in cooling mode the
+# H3C acks M141 and stays off; the H2 series heats in either).
+CHAMBER_HEATER_NEEDS_HEATING_MODE_MODELS = frozenset(["H3C", "N10"])
+
+# `mc_percent` counts the start sequence (an H3C is at ~20% on layer 0), so a
+# stopped print is charged by layers instead (usage_progress).
+PROGRESS_INCLUDES_START_SEQUENCE_MODELS = frozenset(["H3C", "N10"])
+
+AMS3H_MODELS = frozenset(["H3C", "N10"])
+
+
+def _model_key(model: str | None) -> str:
+    return model.strip().upper().replace(" ", "").replace("-", "") if isinstance(model, str) else ""
+
+
+def progress_includes_start_sequence(model: str | None) -> bool:
+    """True when the printer's progress percent counts its start sequence (H3C)."""
+    return _model_key(model) in PROGRESS_INCLUDES_START_SEQUENCE_MODELS
+
+
+def has_ams3h(model: str | None) -> bool:
+    """True for a model that comes with an AMS 3H (H3C)."""
+    return _model_key(model) in AMS3H_MODELS
+
+
+def usage_progress(
+    model: str | None,
+    progress: float | int | None,
+    layer_num: int | None,
+    total_layers: int | None,
+) -> float | int:
+    """How much of a stopped print was printed, in percent, for charging its filament.
+
+    The printer's own progress, except on a model whose progress counts the
+    start sequence: there the share of layers printed (0 before layer 1).
+    """
+    if not progress_includes_start_sequence(model):
+        return progress if progress is not None else 0.0
+    if layer_num and layer_num > 0 and total_layers and total_layers > 0:
+        return min(100.0, 100.0 * layer_num / total_layers)
+    return 0.0
+
+
+def chamber_heater_needs_heating_mode(model: str | None) -> bool:
+    """True when a chamber target only takes effect with the airduct in heating mode (H3C)."""
+    return _model_key(model) in CHAMBER_HEATER_NEEDS_HEATING_MODE_MODELS
+
+
 def is_nozzle_rack_model(model: str | None) -> bool:
     """Return True if the model mounts its nozzles from a swappable rack (H2C).
 

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

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

+ 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)
         assert response.json()["message"] == f"Loading filament from AMS HT-{'A' if tray_id == 128 else 'H'}"
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_load_names_the_ams3h_slot(self, async_client: AsyncClient, printer_factory):
+        """H3C AMS 3H: global tray 28 + slot, named as its slot 1-9."""
+        from backend.app.utils.tray_ids import TrayLayout
+
+        printer = await printer_factory(name="H3C")
+        client = MagicMock()
+        client.tray_layout = TrayLayout.from_units([{"id": 32, "ams_type": 6}])
+        client.rack_tray_has_filament.return_value = True
+        client.ams_load_filament.return_value = True
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = client
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=28")
+
+        assert response.status_code == 200, response.text
+        client.ams_load_filament.assert_called_once_with(28, extruder_id=None)
+        assert response.json()["message"] == "Loading filament from AMS 3H slot 1"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_load_refuses_an_empty_ams3h_slot(self, async_client: AsyncClient, printer_factory):
+        """An AMS 3H slot has nothing to feed until the AMS pulls the filament in at the inlet."""
+        from backend.app.utils.tray_ids import TrayLayout
+
+        printer = await printer_factory(name="H3C")
+        client = MagicMock()
+        client.tray_layout = TrayLayout.from_units([{"id": 32, "ams_type": 6}])
+        client.rack_tray_has_filament.return_value = False
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = client
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/load?tray_id=35")
+
+        assert response.status_code == 409
+        assert "AMS 3H slot 8 has no filament" in response.json()["detail"]
+        client.rack_tray_has_filament.assert_called_once_with(35)
+        client.ams_load_filament.assert_not_called()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize("endpoint", ["load", "unload"])
+    async def test_ams3h_tray_ids_name_nothing_on_another_printer(
+        self, async_client: AsyncClient, printer_factory, endpoint
+    ):
+        """28-36 are AMS 3H slots only on a printer that has one."""
+        printer = await printer_factory(name="X1C")
+        client = MagicMock()
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = client
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/ams/{endpoint}?tray_id=30")
+
+        assert response.status_code == 400
+        client.ams_load_filament.assert_not_called()
+        client.ams_unload_filament.assert_not_called()
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.parametrize("tray_id", [127, 136, 512])

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

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

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

+ 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();
   });
 
+  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 () => {
     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 { render, screen } from '@testing-library/react';
+import { fireEvent, render, screen } from '@testing-library/react';
 import React from 'react';
 import { AmsUnitCard } from '../../../components/spoolbuddy/AmsUnitCard';
 import type { AMSUnit, AMSTray } from '../../../api/client';
@@ -170,4 +170,33 @@ describe('AmsUnitCard', () => {
     const activeSlot = container.querySelector('.ring-2.ring-bambu-green');
     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']);
   });
 });
+
+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 () => {
     server.use(
       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(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 () => {

+ 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');
   });
+
+  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();
     });
   });
+
+  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';
 
 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)', () => {
     it('resolves display name "X2D" to 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'],
     ['N6', 'X2D'],
     ['O1D', 'H2D'],
+    ['N10', 'H3C'],
     ['N2S', 'A1'],
     ['N1', 'A1 Mini'],
     ['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_target_temp: number | null; // Active-cycle target °C (Bambu does not echo)
   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 {
@@ -602,6 +607,7 @@ export interface NozzleRackSlot {
   filament_color: string;  // RGBA hex ("00000000" = no filament)
   filament_id: string;
   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 {
@@ -748,7 +754,7 @@ export interface PrinterStatus {
   // switched to (AMS Filament Backup). Reset at the start of each print.
   tray_change_log?: [number, number][];
   // 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.
   //   expected_tray = the slot the firmware now expects filament in (null = idle,
   //                   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
   created_at: string;
   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 {
@@ -5639,7 +5646,7 @@ export const api = {
       { 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.
   // 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
@@ -7636,6 +7643,7 @@ export const api = {
       tray_id: number;
       spoolman_spool_id: number;
       ams_label: string | null;
+      printer_model?: string | null;
     }>>(
       printerId !== undefined
         ? `/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 {
   computeBackupGroups,
+  isAms3hUnit,
   isAmsHtUnit,
   normalizeColor,
   type AmsUnitLike,
@@ -43,6 +44,7 @@ interface AmsBackupModalProps {
  * counts toward readability.
  */
 function formatSlotLabel(amsId: number, slotIdx: number): string {
+  if (isAms3hUnit({ id: amsId })) return `3H·${slotIdx + 1}`;
   const isHt = isAmsHtUnit(amsId);
   const normalizedId = amsId >= 128 ? amsId - 128 : amsId;
   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 { useToast } from '../contexts/ToastContext';
 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 { getSwatchStyle } from '../utils/colors';
 import { inventoryLocationsQueryKey, invalidateInventoryLocations } from '../utils/inventoryQueries';
@@ -70,8 +71,10 @@ function targetLocationLabel(target: AssignTarget): string {
     : 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');
   return t('printers.external');
 }
@@ -156,17 +159,18 @@ export function QrAssignTargetModal({ isOpen, onClose, spoolmanMode = false }: Q
           trayId: tray.id,
           isHt: ams.is_ams_ht,
           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,
         });
       }
     }
     // 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 rack = trayLayoutOf(status.ams, externals).rack;
     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;
   }, [status, t]);

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

@@ -1,6 +1,6 @@
 import { useTranslation } from 'react-i18next';
 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 {
   if (!color) return '#808080';
@@ -11,12 +11,6 @@ function isTrayEmpty(tray: AMSTray): boolean {
   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) ---
 
 function WaterDropEmpty({ className }: { className?: string }) {
@@ -249,8 +243,9 @@ export function AmsUnitCard({ unit, activeSlot, onConfigureSlot, isDualNozzle, n
   const { t } = useTranslation();
   const trays = unit.tray || [];
   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 (
     <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>
 
       {/* 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) => {
           const tray = trays[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 { AmsUnitCard, NozzleBadge } 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 { spoolSwatchStyle } from './spoolPaint';
 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 {
   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 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 trayLayout = useMemo(() => trayLayoutOf(amsUnits, vtTrays), [amsUnits, vtTrays]);
+  const rackExternals = trayLayout.rack;
   const isDualNozzle = printer?.nozzle_count === 2 || status?.temperatures?.nozzle_2 !== undefined;
 
   const cachedAmsExtruderMap = useRef<Record<string, number>>({});
@@ -342,7 +339,7 @@ export function AssignToAmsModal({
 
   const getTrayForSlot = useCallback((amsId: number, trayId: number): AMSTray | null => {
     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;
     }
     const unit = amsUnits.find(u => u.id === amsId);
@@ -350,14 +347,16 @@ export function AssignToAmsModal({
   }, [amsUnits, vtTrays]);
 
   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');
     // 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.
     if (amsId === 254 || (amsId === 255 && trayId === 0)) return t('printers.extL', 'Ext-L');
     return t('printers.extR', 'Ext-R');
-  }, [t, isDualNozzle]);
+  }, [t, isDualNozzle, rackExternals, trayLayout]);
 
   const doAssign = useCallback((amsId: number, trayId: number) => {
     setStatusType('info');
@@ -451,7 +450,7 @@ export function AssignToAmsModal({
       const amsFill = tray.remain != null && tray.remain >= 0 ? tray.remain : null;
       const resolvedInvFill = (invFill === 0 && amsFill !== null && amsFill > 0) ? null : invFill;
       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),
         nozzleSide: getNozzleSide(unit.id),
         effectiveFill: resolvedInvFill ?? amsFill,
@@ -460,15 +459,17 @@ export function AssignToAmsModal({
 
     for (const extTray of vtTrays) {
       const extTrayId = extTray.id ?? 254;
-      const extSlotTrayId = extTrayId - 254;
+      const extSlotTrayId = externalSlotIndex(extTrayId);
       const extInvFill = fillOverrides[`255-${extSlotTrayId}`] ?? null;
       const extAmsFill = extTray.remain != null && extTray.remain >= 0 ? extTray.remain : null;
       const extResolvedInvFill = (extInvFill === 0 && extAmsFill !== null && extAmsFill > 0) ? null : extInvFill;
       items.push({
         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,
         isEmpty: isTrayEmpty(extTray),
         nozzleSide: isDualNozzle ? (extTrayId === 254 ? 'L' : 'R') : null,
@@ -477,7 +478,7 @@ export function AssignToAmsModal({
     }
 
     return items;
-  }, [htAms, vtTrays, isDualNozzle, t, getNozzleSide, fillOverrides]);
+  }, [htAms, vtTrays, isDualNozzle, t, getNozzleSide, fillOverrides, rackExternals]);
 
   if (!isOpen) return null;
 
@@ -631,8 +632,9 @@ export function AssignToAmsModal({
               {regularAms.length > 0 && (
                 <div className="grid grid-cols-1 xl:grid-cols-2 gap-3">
                   {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
-                      key={unit.id}
                       unit={unit}
                       activeSlot={
                         selectedSlot?.amsId === unit.id
@@ -647,6 +649,7 @@ export function AssignToAmsModal({
                       thresholds={amsThresholds}
                       fillOverrides={fillOverrides}
                     />
+                    </div>
                   ))}
                 </div>
               )}

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

@@ -12,7 +12,9 @@ import {
   isAmsHtUnit,
   preferLowestSortKey,
   compareSortKeys,
+  rackExternalLabel,
 } from '../utils/amsHelpers';
+import { externalSlotIndex, trayLayoutOf } from '../utils/trayIds';
 import type { PrinterStatus, SlotSpoolIdentity } from '../api/client';
 
 /** 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
   // imply dual-nozzle — keep them as fallbacks for any firmware rev that
   // 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 =
     Boolean(printerStatus?.nozzles?.[1]?.nozzle_diameter)
     || (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)
   printerStatus?.ams?.forEach((amsUnit) => {
@@ -71,7 +75,7 @@ export function buildLoadedFilaments(
     amsUnit.tray.forEach((tray) => {
       if (tray.tray_type) {
         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);
         filaments.push({
           type: tray.tray_type,
@@ -81,7 +85,7 @@ export function buildLoadedFilaments(
           trayId: tray.id,
           isHt,
           isExternal: false,
-          label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
+          label: formatSlotLabel(amsUnit.id, tray.id, isHt, false, amsUnit.ams_type),
           globalTrayId,
           trayInfoIdx: tray.tray_info_idx || '',
           traySubBrands: tray.tray_sub_brands || '',
@@ -101,7 +105,7 @@ export function buildLoadedFilaments(
     if (extTray.tray_type) {
       const color = normalizeColor(extTray.tray_color);
       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
       // is how the backend keys it too — see `_ams_key_to_global`.
       const identity = slotSpools?.get(trayId);
@@ -110,10 +114,10 @@ export function buildLoadedFilaments(
         color,
         colorName: identity?.color_name?.trim() || getColorName(color, extTray.tray_sub_brands, extTray.tray_info_idx),
         amsId: -1,
-        trayId: trayId - 254,
+        trayId: externalSlotIndex(trayId),
         isHt: false,
         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,
         trayInfoIdx: extTray.tray_info_idx || '',
         traySubBrands: extTray.tray_sub_brands || '',

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

@@ -421,6 +421,7 @@ export default {
     // Printer card
     readyToPrint: 'Druckbereit',
     external: 'Extern',
+    externalInputsEmpty: 'Alle {{count}} externen Eingänge sind leer',
     ext: 'Extern',
     extL: 'Ext-L',
     extR: 'Ext-R',
@@ -497,6 +498,7 @@ export default {
     activeNozzle: 'Aktiv: {{nozzle}} Düse',
     amsSwitchInletTooltip: 'Filamentweiche IN-{{inlet}} ({{side}}) — versorgt beide Düsen',
     nozzleRack: 'Düsenhalter',
+    nozzleRackMounted: 'Am Werkzeugkopf',
     nozzleDocked: 'Angedockt',
     nozzleMounted: 'Montiert',
     nozzleActive: 'Aktiv',
@@ -570,6 +572,7 @@ export default {
       rfidRereadInitiated: 'RFID-Neueinlesen gestartet',
       loadInitiated: 'Filament wird geladen…',
       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',
       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)',
       stoppingDrying: 'Trocknung wird gestoppt...',
       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.',
       startMode: 'Startzeit',
       modeNow: 'Jetzt',

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

@@ -423,6 +423,7 @@ export default {
     // Printer card
     readyToPrint: 'Ready to print',
     external: 'External',
+    externalInputsEmpty: 'All {{count}} external inputs are empty',
     ext: 'Ext',
     extL: 'Ext-L',
     extR: 'Ext-R',
@@ -502,6 +503,7 @@ export default {
     activeNozzle: 'Active: {{nozzle}} nozzle',
     amsSwitchInletTooltip: 'Filament Track Switch IN-{{inlet}} ({{side}}) — feeds both nozzles',
     nozzleRack: 'Nozzle Rack',
+    nozzleRackMounted: 'On the toolhead',
     nozzleDocked: 'Docked',
     nozzleMounted: 'Mounted',
     nozzleActive: 'Active',
@@ -575,6 +577,7 @@ export default {
       rfidRereadInitiated: 'RFID re-read initiated',
       loadInitiated: 'Loading 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',
       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)',
       stoppingDrying: 'Stopping 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.',
       startMode: 'Start time',
       modeNow: 'Now',

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

@@ -421,6 +421,7 @@ export default {
     // Printer card
     readyToPrint: 'Lista para imprimir',
     external: 'Externo',
+    externalInputsEmpty: 'Las {{count}} entradas externas están vacías',
     ext: 'Externo',
     extL: 'Ext-L',
     extR: 'Ext-R',
@@ -497,6 +498,7 @@ export default {
     activeNozzle: 'Activa: boquilla {{nozzle}}',
     amsSwitchInletTooltip: 'Conmutador de ruta de filamento IN-{{inlet}} ({{side}}): alimenta ambas boquillas',
     nozzleRack: 'Soporte de boquillas',
+    nozzleRackMounted: 'En el cabezal',
     nozzleDocked: 'Acoplada',
     nozzleMounted: 'Montada',
     nozzleActive: 'Activa',
@@ -570,6 +572,7 @@ export default {
       rfidRereadInitiated: 'Relectura de RFID iniciada',
       loadInitiated: 'Cargando 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',
       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)',
       stoppingDrying: 'Deteniendo 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.',
       startMode: 'Hora de inicio',
       modeNow: 'Ahora',

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

@@ -421,6 +421,7 @@ export default {
     // Printer card
     readyToPrint: 'Prête à imprimer',
     external: 'Externe',
+    externalInputsEmpty: 'Les {{count}} entrées externes sont vides',
     ext: 'Externe',
     extL: 'Ext-L',
     extR: 'Ext-R',
@@ -497,6 +498,7 @@ export default {
     activeNozzle: 'Active : buse {{nozzle}}',
     amsSwitchInletTooltip: 'Sélecteur de chemin de filament IN-{{inlet}} ({{side}}) — alimente les deux buses',
     nozzleRack: 'Rack à buses',
+    nozzleRackMounted: 'Sur la tête d\'outil',
     nozzleDocked: 'Rangée',
     nozzleMounted: 'Montée',
     nozzleActive: 'Actif',
@@ -570,6 +572,7 @@ export default {
       rfidRereadInitiated: 'Lecture RFID initiée',
       loadInitiated: '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',
       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)',
       stoppingDrying: 'Arrêt du 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.',
       startMode: 'Heure de démarrage',
       modeNow: 'Maintenant',

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

@@ -421,6 +421,7 @@ export default {
     // Printer card
     readyToPrint: 'Pronta a stampare',
     external: 'Esterna',
+    externalInputsEmpty: 'Tutti i {{count}} ingressi esterni sono vuoti',
     ext: 'Esterno',
     extL: 'Ext-L',
     extR: 'Ext-R',
@@ -497,6 +498,7 @@ export default {
     activeNozzle: 'Attivo: ugello {{nozzle}}',
     amsSwitchInletTooltip: 'Filament Track Switch IN-{{inlet}} ({{side}}) — alimenta entrambi gli ugelli',
     nozzleRack: 'Rack Ugelli',
+    nozzleRackMounted: 'Sulla testina',
     nozzleDocked: 'Agganciato',
     nozzleMounted: 'Montato',
     nozzleActive: 'Attivo',
@@ -570,6 +572,7 @@ export default {
       rfidRereadInitiated: 'Rilettura RFID avviata',
       loadInitiated: 'Caricamento 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',
       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)',
       stoppingDrying: 'Arresto 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.',
       startMode: 'Ora di avvio',
       modeNow: 'Ora',

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

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

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

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

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

@@ -421,6 +421,7 @@ export default {
     // Printer card
     readyToPrint: 'Klaar om af te drukken',
     external: 'Extern',
+    externalInputsEmpty: 'Alle {{count}} externe ingangen zijn leeg',
     ext: 'Extern',
     extL: 'Ext-L',
     extR: 'Ext-R',
@@ -500,6 +501,7 @@ export default {
     activeNozzle: 'Actief: {{nozzle}}-nozzle',
     amsSwitchInletTooltip: 'Filament Track Switch IN-{{inlet}} ({{side}}) — voedt beide nozzles',
     nozzleRack: 'Nozzlerek',
+    nozzleRackMounted: 'Op de printkop',
     nozzleDocked: 'Gedockt',
     nozzleMounted: 'Gemonteerd',
     nozzleActive: 'Actief',
@@ -573,6 +575,7 @@ export default {
       rfidRereadInitiated: 'Opnieuw uitlezen van RFID gestart',
       loadInitiated: 'Filament laden…',
       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',
       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)',
       stoppingDrying: 'Drogen stoppen...',
       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.',
       startMode: 'Starttijd',
       modeNow: 'Nu',

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

@@ -423,6 +423,7 @@ export default {
     // Printer card
     readyToPrint: 'Gotowa do druku',
     external: 'Zewnętrzna',
+    externalInputsEmpty: 'Wszystkie {{count}} wejścia zewnętrzne są puste',
     ext: 'Zewn.',
     extL: 'Zewn.-L',
     extR: 'Zewn.-P',
@@ -502,6 +503,7 @@ export default {
     activeNozzle: 'Aktywna dysza: {{nozzle}}',
     amsSwitchInletTooltip: 'Filament Track Switch IN-{{inlet}} ({{side}}) — zasila obie dysze',
     nozzleRack: 'Magazyn dysz',
+    nozzleRackMounted: 'Na głowicy',
     nozzleDocked: 'Zadokowana',
     nozzleMounted: 'Zamontowana',
     nozzleActive: 'Aktywna',
@@ -575,6 +577,7 @@ export default {
       rfidRereadInitiated: 'Rozpoczęto ponowny odczyt RFID',
       loadInitiated: 'Ładowanie 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',
       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)',
       stoppingDrying: 'Zatrzymywanie 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.',
       startMode: 'Czas rozpoczęcia',
       modeNow: 'Teraz',

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

@@ -421,6 +421,7 @@ export default {
     // Printer card
     readyToPrint: 'Pronto para imprimir',
     external: 'Externo',
+    externalInputsEmpty: 'As {{count}} entradas externas estão vazias',
     ext: 'Externo',
     extL: 'Ext-L',
     extR: 'Ext-R',
@@ -497,6 +498,7 @@ export default {
     activeNozzle: 'Ativo: {{nozzle}} bico',
     amsSwitchInletTooltip: 'Comutador de trajeto de filamento IN-{{inlet}} ({{side}}) — alimenta os dois bicos',
     nozzleRack: 'Suporte de bicos',
+    nozzleRackMounted: 'Na cabeça de impressão',
     nozzleDocked: 'Acoplado',
     nozzleMounted: 'Montado',
     nozzleActive: 'Ativo',
@@ -570,6 +572,7 @@ export default {
       rfidRereadInitiated: 'Releitura de RFID iniciada',
       loadInitiated: 'Carregando 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',
       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)',
       stoppingDrying: 'Parando 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.',
       startMode: 'Horário de início',
       modeNow: 'Agora',

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

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

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

@@ -421,6 +421,7 @@ export default {
     // Printer card
     readyToPrint: 'Redo att skriva ut',
     external: 'Extern',
+    externalInputsEmpty: 'Alla {{count}} externa ingångar är tomma',
     ext: 'Extern',
     extL: 'Ext-V',
     extR: 'Ext-H',
@@ -500,6 +501,7 @@ export default {
     activeNozzle: 'Aktiv: {{nozzle}} munstycke',
     amsSwitchInletTooltip: 'Filamentspårväxlare IN-{{inlet}} ({{side}}) — matar båda munstyckena',
     nozzleRack: 'Munstyckesställ',
+    nozzleRackMounted: 'På verktygshuvudet',
     nozzleDocked: 'Parkerad',
     nozzleMounted: 'Monterad',
     nozzleActive: 'Aktiv',
@@ -573,6 +575,7 @@ export default {
       rfidRereadInitiated: 'Omstart av RFID-läsning påbörjad',
       loadInitiated: 'Laddar 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',
       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)',
       stoppingDrying: 'Stoppar 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.',
       startMode: 'Starttid',
       modeNow: 'Nu',

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

@@ -421,6 +421,7 @@ export default {
     // Yazıcı kartı
     readyToPrint: 'Baskıya hazır',
     external: 'Harici',
+    externalInputsEmpty: '{{count}} harici girişin tümü boş',
     ext: 'Harici',
     extL: 'Ext-L',
     extR: 'Ext-R',
@@ -497,6 +498,7 @@ export default {
     activeNozzle: 'Aktif: {{nozzle}} nozul',
     amsSwitchInletTooltip: 'Filament Hattı Değiştirici IN-{{inlet}} ({{side}}) — her iki nozulu besler',
     nozzleRack: 'Nozul Rafı',
+    nozzleRackMounted: 'Takım kafasında',
     nozzleDocked: 'Yuvalanmış',
     nozzleMounted: 'Takılı',
     nozzleActive: 'Aktif',
@@ -570,6 +572,7 @@ export default {
       rfidRereadInitiated: 'RFID yeniden okuma başlatıldı',
       loadInitiated: 'Filament yükleniyor…',
       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',
       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ı)',
       stoppingDrying: 'Kurutma durduruluyor...',
       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.',
       startMode: 'Başlangıç zamanı',
       modeNow: 'Şimdi',

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

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

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

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

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

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

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

@@ -32,7 +32,7 @@ import { useColorCatalogVersion } from '../hooks/useColorCatalogVersion';
 import { getCurrencySymbol } from '../utils/currency';
 import { shownCostPerKg } from '../utils/spoolCost';
 import { formatDateInput, parseUTCDate, type DateFormat } from '../utils/date';
-import { formatSlotLabel } from '../utils/amsHelpers';
+import { formatAssignmentSlotLabel } from '../utils/amsHelpers';
 import { filterSpoolsByQuery } from '../utils/inventorySearch';
 import {
   inventoryLocationsQueryKey,
@@ -214,6 +214,7 @@ type LocationDisplay = {
   ams_id: number;
   tray_id: number;
   ams_label: string | null;
+  printer_model?: string | null;  // for formatAssignmentSlotLabel
 };
 
 type CellCtx = {
@@ -327,9 +328,7 @@ const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
     const assignment = assignmentMap[spool.id];
     if (!assignment) return <span className="text-sm text-bambu-gray">-</span>;
     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 (
       <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})` : ''}
@@ -576,10 +575,8 @@ const columnSortValues: Record<
   location: (s, am) => {
     const a = am[s.id];
     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})` : '';
-    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(),
   label_weight: (s) => s.label_weight,
@@ -1235,6 +1232,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
         ams_id: a.ams_id,
         tray_id: a.tray_id,
         ams_label: a.ams_label ?? null,
+        printer_model: a.printer_model ?? null,
       };
     }
     for (const a of spoolmanSlotAssignments) {
@@ -1255,6 +1253,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
           ams_id: a.ams_id,
           tray_id: a.tray_id,
           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 { api, discoveryApi, firmwareApi, withMediaToken, ApiError } from '../api/client';
 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 { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
@@ -199,7 +199,8 @@ import { PrintModal } from '../components/PrintModal';
 import { PrinterInfoModal } from '../components/PrinterInfoModal';
 import { FeedDirectionModal } from '../components/FeedDirectionModal';
 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 { FilamentSlotCircle } from '../components/FilamentSlotCircle';
 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();
   // 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
@@ -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
   // is the one currently mounted (#943).
   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 mountedAt = (i: number) =>
+    rackPrinter ? slots.find(s => s.id === 0 && s.addr_id === i) : undefined;
   const rackSlots: (import('../api/client').NozzleRackSlot)[] = Array.from(
     { 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,
       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.
     <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>
-      <div className="flex gap-[3px] justify-center">
+      <div className={`flex gap-[3px] justify-center${rackPrinter ? ' flex-wrap' : ''}`}>
         {rackSlots.map((slot, i) => {
           const isEmpty = !slot.nozzle_diameter && !slot.nozzle_type;
+          const isMounted = rackPrinter && slot.id === 0;
           const filamentBg = !isEmpty ? parseFilamentColor(slot.filament_color) : null;
           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}>
               <div className="flex flex-col items-center gap-0.5">
                 <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 ${
                     isEmpty
                       ? 'bg-bambu-dark-tertiary/20 border-bambu-dark-tertiary/20'
                       : '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}
                 >
                   <span
@@ -783,7 +792,7 @@ function NozzleRackCard({ slots, filamentInfo }: { slots: import('../api/client'
                     than inside it: the chip is barely wider than its own
                     diameter text and already carries that over a
                     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}
                 </span>
               </div>
@@ -1735,6 +1744,7 @@ const MODELS_WITH_CHAMBER_FAN: ReadonlySet<string> = new Set([
   'H2D Pro',
   'H2C',
   'H2S',
+  'H3C',
 ]);
 
 // 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
   const [dryingPopoverAmsId, setDryingPopoverAmsId] = useState<number | null>(null);
   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 [dryingTemp, setDryingTemp] = useState(50);
   const [dryingDuration, setDryingDuration] = useState(4);
@@ -2204,12 +2216,15 @@ function PrinterCard({
   // 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
   // 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);
     // Only reachable if a custom preset set dropped PLA itself.
     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);
     setDryingTemp(preset[moduleType] || preset.n3f);
     setDryingDuration(moduleType === 'n3s' ? preset.n3s_hours : preset.n3f_hours);
@@ -2217,7 +2232,8 @@ function PrinterCard({
     setDryingStartMode('now');
     setDryingDelayMinutes(120);
     setDryingStartAt('');
-    setDryingPopoverModuleType(ams.module_type);
+    setDryingPopoverModuleType(moduleType);
+    setDryingPopoverDrySlots(dryer ? dryer.slots : null);
     setDryingPopoverAmsId(ams.id);
     setDryingPopoverAnchor(trigger);
   }, [dryingPresets]);
@@ -2551,17 +2567,20 @@ function PrinterCard({
   // so these are often different — the graphic highlights the expected one.
   const expectedTray = status?.expected_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
   // HMS modal (#2587). null when the slot can't be placed → honest fallback copy.
   const formatRunoutSlotLabel = (globalId: number | null): string | 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 >= 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 });
   };
   const runoutGuidance = status?.state === 'PAUSE'
@@ -3231,7 +3250,14 @@ function PrinterCard({
 
   const unloadAmsMutation = useMutation({
     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'));
     },
     onError: (error: Error) => {
@@ -4219,7 +4245,7 @@ function PrinterCard({
 
               {/* Enclosure Door Badge — models with an actual door sensor.
                   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
                   className={`flex items-center px-2 py-1 rounded-full text-xs ${
                     status.door_open
@@ -4890,7 +4916,11 @@ function PrinterCard({
                     )}
                     {/* 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) && (
-                      <NozzleRackCard slots={status.nozzle_rack} filamentInfo={filamentInfo} />
+                      <NozzleRackCard
+                        slots={status.nozzle_rack}
+                        filamentInfo={filamentInfo}
+                        rackPrinter={trayLayout.rack}
+                      />
                     )}
                   </div>
                   <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)]" />
                       </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 Icon = isHeating ? Flame : Snowflake;
                         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 => ({
                 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
               // "P1 / P2 / P3" pill on each slot referenced by the active print's
               // 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)
                 ? status.ams_mapping
                 : [];
-              const getAmsCardStyle = (slotCount: number): React.CSSProperties => {
+              const getAmsCardStyle = (slotCount: number, gaps?: number): React.CSSProperties => {
                 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)`;
                 return {
                   flex: `1 1 ${minWidth}`,
@@ -5419,9 +5452,24 @@ function PrinterCard({
                     {/* Regular AMS units */}
                     {regularAms.map((ams) => {
                       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 (
-                        <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) */}
                             <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">
@@ -5429,13 +5477,13 @@ function PrinterCard({
                                 <AmsNameHoverCard
                                   ams={ams}
                                   printerId={printer.id}
-                                  label={getAmsLabel(ams.id)}
+                                  label={cardLabel}
                                   amsLabels={amsLabels}
                                   canEdit={hasPermission('printers:update')}
                                   onSaved={refetchAmsLabels}
                                 >
                                   <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>
                                 </AmsNameHoverCard>
                                 {sideBadge?.kind === 'inlet' ? (
@@ -5459,7 +5507,7 @@ function PrinterCard({
                                       fairThreshold={amsThresholds?.humidityFair}
                                       onClick={() => setAmsHistoryModal({
                                         amsId: ams.id,
-                                        amsLabel: getAmsLabel(ams.id),
+                                        amsLabel: cardLabel,
                                         mode: 'humidity',
                                       })}
                                       compact
@@ -5473,7 +5521,7 @@ function PrinterCard({
                                         fairThreshold={amsThresholds?.tempFair}
                                         onClick={() => setAmsHistoryModal({
                                           amsId: ams.id,
-                                          amsLabel: getAmsLabel(ams.id),
+                                          amsLabel: cardLabel,
                                           mode: 'temperature',
                                         })}
                                         compact
@@ -5483,7 +5531,7 @@ function PrinterCard({
                                   {/* Drying button — only for AMS 2 Pro (n3f) and AMS-HT (n3s).
                                       Screen-only models (P1 series) keep the control but can't
                                       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
                                       disabled={status.drying_screen_only || !!(ams.dry_sf_reason?.length && ams.dry_time === 0)}
                                       onClick={(e) => {
@@ -5492,7 +5540,11 @@ function PrinterCard({
                                         } else if (dryingPopoverAmsId === ams.id) {
                                           setDryingPopoverAmsId(null);
                                         } 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 ${
@@ -5575,17 +5627,37 @@ function PrinterCard({
                                 )}
                               </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)
                                 const tray = trayForSlot(ams.tray, slotIdx);
                                 const hasFillLevel = tray?.tray_type && tray.remain >= 0;
                                 const isEmpty = !tray?.tray_type;
                                 const emptyKind = getEmptySlotKind(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;
                                 // Runout guidance (#2587): the slot the paused print now
                                 // 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)
                                 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 */}
                                     {isRefreshing && (
                                       <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({
                                           amsId: ams.id,
                                           slotId: slotIdx,
-                                          loadTrayId: ams.id * 4 + slotIdx,
+                                          loadTrayId: globalTrayId,
                                           isRefreshing,
                                         })}
                                         spoolman={{
@@ -5793,7 +5865,7 @@ function PrinterCard({
                                                   material: tray?.tray_type ?? undefined,
                                                   profile: filamentData.profile,
                                                   color: filamentData.colorHex || '',
-                                                  location: `${getAmsLabel(ams.id)} Slot ${slotIdx + 1}`,
+                                                  location: `${cardLabel} Slot ${slotIdx + 1}`,
                                                 },
                                               }),
                                               onUnassignSpool: (spoolmanSpool && !isBambuLabSpool(tray)) ? () => onUnassignSpoolmanSpool?.(spoolmanSpool.id) : undefined,
@@ -5826,7 +5898,7 @@ function PrinterCard({
                                                 material: tray?.tray_type ?? undefined,
                                                 profile: filamentData.profile,
                                                 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,
@@ -5857,7 +5929,7 @@ function PrinterCard({
                                         actions={renderAmsSlotActions({
                                           amsId: ams.id,
                                           slotId: slotIdx,
-                                          loadTrayId: ams.id * 4 + slotIdx,
+                                          loadTrayId: globalTrayId,
                                           isRefreshing,
                                         })}
                                         configureSlot={{
@@ -5877,7 +5949,7 @@ function PrinterCard({
                                             material: undefined,
                                             profile: '',
                                             color: '',
-                                            location: `${getAmsLabel(ams.id)} Slot ${slotIdx + 1}`,
+                                            location: `${cardLabel} Slot ${slotIdx + 1}`,
                                             emptyKind,
                                           },
                                         })}
@@ -6339,14 +6411,25 @@ function PrinterCard({
                           </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">
                             <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 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;
                               // On dual-nozzle (H2C/H2D), tray_now=254 means "external spool"
                               // generically — use active_extruder to determine L vs R:
@@ -6355,10 +6438,19 @@ function PrinterCard({
                                 ? (extTrayId === 254 && status.active_extruder === 1) ||
                                   (extTrayId === 255 && status.active_extruder === 0)
                                 : 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 extSlotPreset = slotPresets?.[255 * 4 + slotTrayId];
                               // Only trusted while it still describes what the printer reports in the
@@ -6433,7 +6525,7 @@ function PrinterCard({
                                     isEmpty={isEmpty}
                                     emptyKind={emptyKind}
                                     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'}`}>
                                     {extTray.tray_type || pending?.material || t('ams.slotEmpty')}
@@ -6458,12 +6550,7 @@ function PrinterCard({
                                   {!isEmpty ? (
                                     <FilamentHoverCard
                                       data={extFilamentData}
-                                      actions={renderAmsSlotActions({
-                                        amsId: 255,
-                                        slotId: slotTrayId,
-                                        loadTrayId: extTrayId,
-                                        includeRfid: false,
-                                      })}
+                                      actions={extActions}
                                       spoolman={{
                                         enabled: spoolmanEnabled,
                                         // #1457: slot assignment outranks tag-link (see top-level slot block).
@@ -6564,12 +6651,7 @@ function PrinterCard({
                                     <EmptySlotHoverCard
                                       kind={emptyKind ?? undefined}
                                       assigned={pending ?? undefined}
-                                      actions={renderAmsSlotActions({
-                                        amsId: 255,
-                                        slotId: slotTrayId,
-                                        loadTrayId: extTrayId,
-                                        includeRfid: false,
-                                      })}
+                                      actions={extActions}
                                       configureSlot={{
                                         enabled: hasPermission('printers:control'),
                                         onConfigure: () => setConfigureSlotModal({
@@ -6587,7 +6669,7 @@ function PrinterCard({
                                           material: undefined,
                                           profile: '',
                                           color: '',
-                                          location: `External Slot ${slotTrayId + 1}`,
+                                          location: trayLayout.rack ? extLabel : `External Slot ${slotTrayId + 1}`,
                                           emptyKind,
                                         },
                                       })}
@@ -6600,7 +6682,8 @@ function PrinterCard({
                             })}
                           </div>
                         </div>
-                      )}
+                        );
+                      })()}
                   </div>
                 </div>
               );
@@ -7490,6 +7573,11 @@ function PrinterCard({
               <div className="shrink-0 h-px bg-bambu-dark-tertiary" />
               {/* Body */}
               <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 */}
                 <div>
                   <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
                     ? amsData.find(a => a.id === dryingPopoverAmsId)
                     : 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 rotateChecked = dryingRotateTray && !trayLoadedInThisAms;
                   return (
@@ -8129,6 +8219,9 @@ export function AddPrinterModal({
                   <option value="H2D Pro">H2D Pro</option>
                   <option value="H2S">H2S</option>
                 </optgroup>
+                <optgroup label="H3 Series">
+                  <option value="H3C">H3C</option>
+                </optgroup>
                 <optgroup label="P Series">
                   <option value="P1P">P1P</option>
                   <option value="P1S">P1S</option>
@@ -8697,6 +8790,9 @@ function EditPrinterModal({
                   <option value="H2D Pro">H2D Pro</option>
                   <option value="H2S">H2S</option>
                 </optgroup>
+                <optgroup label="H3 Series">
+                  <option value="H3C">H3C</option>
+                </optgroup>
                 <optgroup label="P Series">
                   <option value="P1P">P1P</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 { productLineOf, useFilamentNameByIdx } from '../components/PrintModal/useFilamentLabels';
 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 { Card } from '../components/Card';
 import { FilamentSwatch } from '../components/FilamentSwatch';
@@ -119,16 +120,18 @@ type QueueFilamentDisplay = {
  * tray_type either, #2527), so the card only warns on a positive finding.
  */
 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 tray = isHt ? unit?.tray[0] : unit?.tray.find((candidate) => candidate.id === slot);
   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 { api } 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 { mapModelCode } from '../../utils/printerModel';
 import { spoolSwatchStyle } from '../../components/spoolbuddy/spoolPaint';
@@ -35,12 +36,6 @@ import { LinkSpoolModal } from '../../components/LinkSpoolModal';
 import { useToast } from '../../contexts/ToastContext';
 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 {
   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 => {
     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;
-  }, [effectiveTrayNow]);
+  }, [effectiveTrayNow, trayLayout]);
 
   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 mappedExtruderId = amsExtruderMap[String(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)
         ? slotPreset?.preset_id
         : undefined,
-      location: `${getAmsName(amsId)} Slot ${trayId + 1}`,
+      location: `${spoolBuddyAmsName(amsId, amsType)} Slot ${trayId + 1}`,
     };
 
     setSlotActionPicker(slotData);
-  }, [slotPresets, amsExtruderMap, isDualNozzle]);
+  }, [slotPresets, amsExtruderMap, isDualNozzle, trayLayout]);
 
   const handleExtSlotClick = useCallback((extTray: AMSTray) => {
     const extTrayId = extTray.id ?? 254;
-    const slotTrayId = extTrayId - 254;
+    const slotTrayId = externalSlotIndex(extTrayId);
     const extSlotPreset = slotPresets?.[255 * 4 + slotTrayId];
     const slotData = {
       amsId: 255,
@@ -416,13 +410,15 @@ export function SpoolBuddyAmsPage() {
       savedPresetId: slotPresetDescribesTray(extSlotPreset?.preset_id, extTray.tray_info_idx, extSlotPreset?.tray_info_idx)
         ? extSlotPreset?.preset_id
         : 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);
-  }, [slotPresets, isDualNozzle]);
+  }, [slotPresets, isDualNozzle, trayLayout.rack]);
 
   const openConfigureFromPicker = useCallback(() => {
     if (!slotActionPicker) return;
@@ -518,7 +514,7 @@ export function SpoolBuddyAmsPage() {
       const resolvedInvFill = (invFill === 0 && amsFill !== null && amsFill > 0) ? null : invFill;
       items.push({
         key: `ht-${unit.id}`,
-        label: getAmsName(unit.id),
+        label: spoolBuddyAmsName(unit.id),
         tray,
         isEmpty: isTrayEmpty(tray),
         isActive: getActiveSlotForAms(unit.id) === 0,
@@ -539,7 +535,7 @@ export function SpoolBuddyAmsPage() {
         ? (extTrayId === 254 && status?.active_extruder === 1) ||
           (extTrayId === 255 && status?.active_extruder === 0)
         : effectiveTrayNow === extTrayId;
-      const extSlotTrayId = extTrayId - 254;
+      const extSlotTrayId = externalSlotIndex(extTrayId);
       // Fill level fallback chain: Spoolman → Inventory → AMS remain
       const extSpoolmanFill = getSpoolmanFillForSlot(255, extSlotTrayId, isTrayEmpty(extTray) ? null : extTray);
       const extInvFill = fillOverrides[`255-${extSlotTrayId}`] ?? null;
@@ -548,9 +544,11 @@ export function SpoolBuddyAmsPage() {
       const extResolvedInvFill = (extInvFill === 0 && extAmsFill !== null && extAmsFill > 0) ? null : extInvFill;
       items.push({
         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,
         isEmpty: isTrayEmpty(extTray),
         isActive: isExtActive,
@@ -561,7 +559,7 @@ export function SpoolBuddyAmsPage() {
     }
 
     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 (
     <div className="h-full flex flex-col p-4">
@@ -592,8 +590,9 @@ export function SpoolBuddyAmsPage() {
             {/* Regular AMS cards — 4-slot, 2-col grid */}
             <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
               {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
-                  key={unit.id}
                   unit={unit}
                   activeSlot={getActiveSlotForAms(unit.id)}
                   onConfigureSlot={handleAmsSlotClick}
@@ -603,6 +602,7 @@ export function SpoolBuddyAmsPage() {
                   fillOverrides={fillOverrides}
                   spoolmanFillOverrides={spoolmanFillOverrides}
                 />
+                </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 { SpoolPaint, SpoolShade } from '../../components/spoolbuddy/SpoolIcon';
 import { hasFilamentPaint, spoolSwatchStyle } from '../../components/spoolbuddy/spoolPaint';
-import { formatSlotLabel } from '../../utils/amsHelpers';
+import { formatAssignmentSlotLabel } from '../../utils/amsHelpers';
 import { filterSpoolsByQuery } from '../../utils/inventorySearch';
 import { useColorCatalogVersion } from '../../hooks/useColorCatalogVersion';
 import { InventorySpoolInfoCard } from '../../components/spoolbuddy/InventorySpoolInfoCard';
@@ -17,7 +17,7 @@ import { QrAssignTargetModal } from '../../components/QrAssignTargetModal';
 import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
 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
 
@@ -41,9 +41,7 @@ function spoolDisplayName(spool: InventorySpool): 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 */
@@ -137,11 +135,11 @@ export function SpoolBuddyInventoryPage() {
   const assignmentMap = useMemo((): Record<number, SlotInfo> => {
     if (spoolmanMode) {
       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;
     }
     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;
   }, [spoolmanMode, assignments, spoolmanAssignments]);
 

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

@@ -5,6 +5,18 @@
  */
 import { parseUTCDate } from './date';
 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.
@@ -74,12 +86,15 @@ export function resolveSlotExtruder(
   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
  * 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 safeId = isNaN(id) ? 0 : id;
   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,
   // self-scoping label for the Lite's 4-slot unit.
   if (safeId === 6) return 'AMS Lite';
+  if (isAms3hUnit({ id: safeId, ams_type: amsType })) return AMS3H_LABEL;
   const isHt = isAmsHtUnit(safeId);
   const normalizedId = safeId >= 128 ? safeId - 128 : safeId;
   const letter = String.fromCharCode(65 + normalizedId);
   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.
  * Types within the same group are interchangeable on the printer side
@@ -362,9 +438,11 @@ export function formatSlotLabel(
   amsId: number,
   trayId: number,
   isHt: boolean,
-  isExternal: boolean
+  isExternal: boolean,
+  amsType?: number | null,
 ): string {
   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)
   // AMS-HT uses IDs starting at 128
   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 trayId - Tray/slot ID within the AMS unit
  * @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(
   amsId: number,
   trayId: number,
-  isExternal: boolean
+  isExternal: boolean,
+  amsType?: number | null,
 ): 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.
  */
 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.
  */
 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,
   extruderSlots: Record<string, { ams_id: number | null; has_filament: boolean }> | null | undefined,
 ): boolean {
   if (!ams) return false;
   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);
 }
 
@@ -499,7 +577,7 @@ export function isPlaceholderDate(scheduledTime: string | null | undefined): boo
 function slotPriority(amsId: number | undefined, trayId: number | undefined): number {
   if (amsId == null || amsId < 0) return 10_000;
   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 {
   id: number;
+  ams_type?: number | null;
   tray: AmsTrayLike[];
 }
 
@@ -817,7 +896,7 @@ export function computeBackupGroups(
     ams.tray.forEach((tray, slotIdx) => {
       if (!tray?.tray_type) return; // empty slot
       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 };
 
       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)
  *   - A1, A1 Mini    → linear rails (Y axis)
  *   - 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),
  * 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 isH2S = model.includes('H2S');
   const isH2 = isH2D || isH2C || isH2S;
+  const isH3C = model.includes('H3C') || model === 'N10';
   const isP2S = model.includes('P2S');
   const isX2D = model.includes('X2D');
   // 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 (isH2C) return 'https://wiki.bambulab.com/en/h2c/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';
       // 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).

+ 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('h2d')) return '/img/printers/h2d.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('p2s')) return '/img/printers/p2s.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',
     'O1C2': 'H2C',
     'O1S': 'H2S',
+    // H3 Series (model_id in BambuStudio's "Bambu Lab H3C.json")
+    'N10': 'H3C',
     // X1 Series
     'BL-P001': 'X1C',
     'BL-P002': 'X1',
@@ -44,6 +46,7 @@ export function mapModelCode(ssdpModel: string | null): string {
     'H2D Pro': 'H2D Pro',
     'H2C': 'H2C',
     'H2S': 'H2S',
+    'H3C': 'H3C',
   };
   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];
+}