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

Merge pull request #2973 from maziggy/refactor/default-profiles

Configure a spool's filament preset and K profile per nozzle

Adds per-printer-model filament presets and per-hotend K profiles to a
spool, and makes every path that configures an AMS slot respect them.

See the CHANGELOG entry for the user-facing description.
MartinNYHC 1 неделя назад
Родитель
Сommit
35ee7352d3
62 измененных файлов с 4925 добавлено и 360 удалено
  1. 0 0
      CHANGELOG.md
  2. 102 14
      backend/app/api/routes/inventory.py
  3. 111 9
      backend/app/api/routes/printers.py
  4. 52 23
      backend/app/api/routes/spoolman.py
  5. 119 18
      backend/app/api/routes/spoolman_inventory.py
  6. 1 0
      backend/app/core/database.py
  7. 26 16
      backend/app/main.py
  8. 3 0
      backend/app/models/__init__.py
  9. 8 0
      backend/app/models/spool.py
  10. 90 0
      backend/app/models/spool_filament_preset.py
  11. 25 0
      backend/app/schemas/spool.py
  12. 6 1
      backend/app/services/bambu_mqtt.py
  13. 48 6
      backend/app/services/slot_kprofile.py
  14. 179 0
      backend/app/services/slot_nozzle.py
  15. 127 0
      backend/app/services/spool_filament_preset.py
  16. 39 12
      backend/app/services/spool_tag_matcher.py
  17. 1 0
      backend/tests/conftest.py
  18. 168 0
      backend/tests/integration/test_slot_spool_defaults.py
  19. 234 0
      backend/tests/integration/test_spool_filament_preset_endpoints.py
  20. 179 0
      backend/tests/unit/test_assign_uses_model_preset_override.py
  21. 321 0
      backend/tests/unit/test_rfid_assign_picks_the_right_hotend.py
  22. 179 0
      backend/tests/unit/test_slot_nozzle_resolution.py
  23. 206 0
      backend/tests/unit/test_spool_filament_preset_cascade.py
  24. 1 0
      frontend/scripts/check-i18n-parity.mjs
  25. 701 0
      frontend/src/__tests__/components/PrinterProfilesSection.test.tsx
  26. 10 6
      frontend/src/__tests__/components/SpoolFormBulk.test.tsx
  27. 8 4
      frontend/src/__tests__/components/SpoolFormEditRelaxed.test.tsx
  28. 63 3
      frontend/src/__tests__/components/SpoolFormModal.test.tsx
  29. 118 0
      frontend/src/__tests__/components/spool-form/fetchPrinterCalibrations.test.ts
  30. 66 2
      frontend/src/api/client.ts
  31. 49 81
      frontend/src/components/ConfigureAmsSlotModal.tsx
  32. 235 104
      frontend/src/components/SpoolFormModal.tsx
  33. 30 23
      frontend/src/components/spool-form/AdditionalSection.tsx
  34. 10 2
      frontend/src/components/spool-form/FilamentSection.tsx
  35. 188 0
      frontend/src/components/spool-form/PresetPicker.tsx
  36. 650 0
      frontend/src/components/spool-form/PrinterProfilesSection.tsx
  37. 10 0
      frontend/src/components/spool-form/constants.ts
  38. 52 0
      frontend/src/components/spool-form/types.ts
  39. 83 23
      frontend/src/components/spool-form/utils.ts
  40. 19 0
      frontend/src/i18n/locales/de.ts
  41. 19 0
      frontend/src/i18n/locales/en.ts
  42. 19 0
      frontend/src/i18n/locales/es.ts
  43. 19 0
      frontend/src/i18n/locales/fr.ts
  44. 19 0
      frontend/src/i18n/locales/it.ts
  45. 19 0
      frontend/src/i18n/locales/ja.ts
  46. 19 0
      frontend/src/i18n/locales/ko.ts
  47. 19 0
      frontend/src/i18n/locales/nl.ts
  48. 19 0
      frontend/src/i18n/locales/pt-BR.ts
  49. 19 0
      frontend/src/i18n/locales/ru.ts
  50. 19 0
      frontend/src/i18n/locales/tr.ts
  51. 19 0
      frontend/src/i18n/locales/uk.ts
  52. 19 0
      frontend/src/i18n/locales/zh-CN.ts
  53. 19 0
      frontend/src/i18n/locales/zh-TW.ts
  54. 10 5
      frontend/src/pages/PrintersPage.tsx
  55. 7 1
      frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx
  56. 8 4
      frontend/src/utils/amsHelpers.ts
  57. 57 0
      frontend/src/utils/nozzleFlow.ts
  58. 76 0
      frontend/src/utils/slicerPrinterMatch.ts
  59. 0 1
      static/assets/index-BzJRM4M1.css
  60. 0 0
      static/assets/index-CLXSCni4.js
  61. 1 0
      static/assets/index-q2IPtdZB.css
  62. 2 2
      static/index.html

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CHANGELOG.md


+ 102 - 14
backend/app/api/routes/inventory.py

@@ -26,6 +26,7 @@ from backend.app.models.settings import Settings
 from backend.app.models.spool import Spool
 from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.models.spool_catalog import SpoolCatalogEntry
+from backend.app.models.spool_filament_preset import SpoolFilamentPreset
 from backend.app.models.spool_k_profile import SpoolKProfile
 from backend.app.models.user import User
 from backend.app.schemas.location import LocationCreate, LocationResponse, LocationUpdate
@@ -34,6 +35,8 @@ from backend.app.schemas.spool import (
     SpoolAssignmentResponse,
     SpoolBulkCreate,
     SpoolCreate,
+    SpoolFilamentPresetBase,
+    SpoolFilamentPresetResponse,
     SpoolKProfileBase,
     SpoolKProfileResponse,
     SpoolResponse,
@@ -53,6 +56,7 @@ from backend.app.services.location_service import (
     rename_location as rename_location_record,
 )
 from backend.app.services.slicer_filament_resolver import resolve_slicer_filament
+from backend.app.services.slot_nozzle import resolve_slot_nozzle
 from backend.app.services.spool_csv import (
     MAX_CSV_IMPORT_BYTES,
     ImportPreview,
@@ -60,6 +64,7 @@ from backend.app.services.spool_csv import (
     parse_and_validate,
     serialize,
 )
+from backend.app.services.spool_filament_preset import resolve_spool_preset
 from backend.app.services.spoolman import SpoolmanClient, get_spoolman_client, init_spoolman_client
 from backend.app.utils.filament_ids import (
     GENERIC_FILAMENT_IDS,
@@ -130,6 +135,27 @@ async def apply_spool_to_slot_via_mqtt(
 
     _generic_id_values = _GENERIC_ID_VALUES
 
+    # Which nozzle this slot feeds, and how wide it is. One resolution shared
+    # with every other path that configures a slot (see services.slot_nozzle),
+    # and used twice below -- for the spool's per-model preset override and for
+    # its K profile -- so the two lookups cannot answer for different nozzles.
+    slot_nozzle = resolve_slot_nozzle(state, ams_id, tray_id, printer_manager.get_model(printer_id))
+    nozzle_diameter = slot_nozzle.diameter
+
+    # A cloud or Orca preset is bound to a printer MODEL ("@BBL X1C"), so the
+    # spool's single slicer_filament stops being right the moment the same
+    # spool is used on a second model. resolve_spool_preset returns the
+    # spool's own value unless the user has set an override for this model,
+    # so a spool nobody has configured behaves exactly as it did before.
+    slot_slicer_filament, slot_slicer_filament_name = await resolve_spool_preset(
+        db,
+        spool_id=spool.id,
+        printer_model=printer_manager.get_model(printer_id),
+        nozzle_diameter=nozzle_diameter,
+        fallback_filament=spool.slicer_filament,
+        fallback_name=spool.slicer_filament_name,
+    )
+
     # slicer_filament → (tray_info_idx, setting_id) resolution is shared with
     # the Spoolman-mode route via this helper (#1713). The helper handles
     # GFS/PFUS/PFCN cloud lookup, GF normalize, integer LocalPreset id,
@@ -139,8 +165,8 @@ async def apply_spool_to_slot_via_mqtt(
     tray_info_idx, setting_id, sub_brand_override, type_override = await resolve_slicer_filament(
         db=db,
         current_user=current_user,
-        slicer_filament=spool.slicer_filament,
-        slicer_filament_name=spool.slicer_filament_name,
+        slicer_filament=slot_slicer_filament,
+        slicer_filament_name=slot_slicer_filament_name,
         material=spool.material,
     )
     if sub_brand_override:
@@ -198,18 +224,7 @@ async def apply_spool_to_slot_via_mqtt(
     if spool.nozzle_temp_max is not None:
         temp_max = spool.nozzle_temp_max
 
-    nozzle_diameter = "0.4"
-    if state and state.nozzles:
-        nd = state.nozzles[0].nozzle_diameter
-        if nd:
-            nozzle_diameter = nd
-
-    slot_extruder = None
-    if state and state.ams_extruder_map:
-        if ams_id == 255:
-            slot_extruder = 1 - tray_id  # ext-L (tray 0) → extruder 1, ext-R (tray 1) → extruder 0
-        else:
-            slot_extruder = state.ams_extruder_map.get(str(ams_id))
+    slot_extruder = slot_nozzle.extruder
 
     # Prefer exact extruder match, fall back to extruder-agnostic kp for the
     # same nozzle. Hard-skipping on mismatch silently drops valid stored
@@ -219,6 +234,12 @@ async def apply_spool_to_slot_via_mqtt(
     for kp in spool.k_profiles:
         if kp.printer_id != printer_id or kp.nozzle_diameter != nozzle_diameter:
             continue
+        # A profile measured on a high-flow nozzle is not a fact about a
+        # standard one. Rows with no stored flow -- everything saved before
+        # this, and everything from a printer whose table declares none --
+        # still match, see SlotNozzle.flow_matches.
+        if not slot_nozzle.flow_matches(kp.nozzle_type):
+            continue
         if slot_extruder is not None and kp.extruder is not None and kp.extruder == slot_extruder:
             exact_kp = kp
             break
@@ -1667,6 +1688,73 @@ async def replace_k_profiles(
     return new_profiles
 
 
+@router.get("/spools/{spool_id}/filament-presets", response_model=list[SpoolFilamentPresetResponse])
+async def list_filament_presets(
+    spool_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+):
+    """List per-printer-model preset overrides for a spool.
+
+    A dedicated endpoint rather than a field on ``SpoolResponse``: the
+    inventory list returns every spool the user owns, and only the spool form
+    and the assign path ever need this list, one spool at a time.
+    """
+    result = await db.execute(select(SpoolFilamentPreset).where(SpoolFilamentPreset.spool_id == spool_id))
+    return list(result.scalars().all())
+
+
+@router.put("/spools/{spool_id}/filament-presets", response_model=list[SpoolFilamentPresetResponse])
+async def replace_filament_presets(
+    spool_id: int,
+    presets: list[SpoolFilamentPresetBase],
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Replace all per-printer-model preset overrides for a spool.
+
+    Replace rather than merge, matching the K-profile endpoint next door: the
+    spool form always holds the complete set, and an empty list is how the
+    user clears every override back to the spool's own preset.
+    """
+    result = await db.execute(select(Spool).where(Spool.id == spool_id))
+    if not result.scalar_one_or_none():
+        raise HTTPException(404, "Spool not found")
+
+    # (model, diameter) is UNIQUE, so a payload that names one twice would
+    # fail on flush with an IntegrityError the client cannot act on. Reject it
+    # by name instead -- and reject it BEFORE deleting the existing rows, so a
+    # bad request cannot wipe overrides it then fails to replace.
+    seen: set[tuple[str, str]] = set()
+    for p in presets:
+        key = (p.printer_model, p.nozzle_diameter)
+        if key in seen:
+            raise HTTPException(
+                422,
+                f"Duplicate override for model {p.printer_model!r} nozzle {p.nozzle_diameter or 'any'!r}",
+            )
+        seen.add(key)
+
+    existing = await db.execute(select(SpoolFilamentPreset).where(SpoolFilamentPreset.spool_id == spool_id))
+    for old in existing.scalars().all():
+        await db.delete(old)
+    # Land the deletes before the inserts: within one transaction SQLAlchemy is
+    # free to order the INSERTs first, which trips the UNIQUE constraint
+    # against rows this call is about to remove.
+    await db.flush()
+
+    new_presets = []
+    for p in presets:
+        row = SpoolFilamentPreset(spool_id=spool_id, **p.model_dump())
+        db.add(row)
+        new_presets.append(row)
+
+    await db.commit()
+    for row in new_presets:
+        await db.refresh(row)
+    return new_presets
+
+
 # ── Spool Assignments ────────────────────────────────────────────────────────
 
 

+ 111 - 9
backend/app/api/routes/printers.py

@@ -87,6 +87,7 @@ from backend.app.services.printer_media import (
     remove_printer_files_zip,
     start_printer_files_job,
 )
+from backend.app.services.slot_nozzle import resolve_slot_nozzle
 from backend.app.utils.filament_ids import filament_id_to_setting_id
 from backend.app.utils.filament_types import printer_filament_type
 from backend.app.utils.fts_routing import slot_extruder
@@ -2651,6 +2652,106 @@ async def delete_slot_preset(
     return {"success": True}
 
 
+@router.get("/{printer_id}/slots/{ams_id}/{tray_id}/spool-defaults")
+async def get_slot_spool_defaults(
+    printer_id: int,
+    ams_id: int,
+    tray_id: int,
+    db: AsyncSession = Depends(get_db),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+):
+    """What the spool assigned to this slot is configured to use here.
+
+    The Configure AMS Slot dialog opens on a slot that usually already holds an
+    assigned spool, and that spool carries a filament preset per printer model
+    and a K profile per hotend. Without this the dialog offered defaults derived
+    from the slot's last manual configuration or from the tray's RFID data --
+    ignoring the very values the spool was configured with, on the one screen
+    that looks like it exists for them.
+
+    Everything is resolved for the nozzle THIS slot feeds, so a dual-nozzle
+    machine gets the answer for the correct hotend. Returns nulls rather than a
+    404 when the slot holds no known spool: "nothing configured" is an ordinary
+    answer here and the dialog falls back to what it did before.
+    """
+    from backend.app.models.spool import Spool
+    from backend.app.models.spool_assignment import SpoolAssignment
+    from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+    from backend.app.services.inventory_mode import spoolman_owns_assignments
+    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 resolve_spool_preset, resolve_spoolman_preset
+
+    state = printer_manager.get_status(printer_id)
+    model = printer_manager.get_model(printer_id)
+    slot_nozzle = resolve_slot_nozzle(state, ams_id, tray_id, model)
+
+    profile = await find_slot_kprofile_for_extruder(
+        db,
+        printer_id,
+        ams_id,
+        tray_id,
+        slot_nozzle.extruder_or_default,
+        slot_nozzle.diameter,
+        model,
+        slot_nozzle.flow,
+    )
+
+    slicer_filament: str | None = None
+    slicer_filament_name: str | None = None
+    spoolman_mode = await spoolman_owns_assignments(db)
+    if spoolman_mode:
+        sm_assignment = (
+            await db.execute(
+                select(SpoolmanSlotAssignment).where(
+                    SpoolmanSlotAssignment.printer_id == printer_id,
+                    SpoolmanSlotAssignment.ams_id == ams_id,
+                    SpoolmanSlotAssignment.tray_id == tray_id,
+                )
+            )
+        ).scalar_one_or_none()
+        if sm_assignment is not None:
+            slicer_filament, slicer_filament_name = await resolve_spoolman_preset(
+                db,
+                spoolman_spool_id=sm_assignment.spoolman_spool_id,
+                printer_model=model,
+                nozzle_diameter=slot_nozzle.diameter,
+                fallback_filament=None,
+                fallback_name=None,
+            )
+    else:
+        assignment = (
+            await db.execute(
+                select(SpoolAssignment).where(
+                    SpoolAssignment.printer_id == printer_id,
+                    SpoolAssignment.ams_id == ams_id,
+                    SpoolAssignment.tray_id == tray_id,
+                )
+            )
+        ).scalar_one_or_none()
+        if assignment is not None:
+            spool = (await db.execute(select(Spool).where(Spool.id == assignment.spool_id))).scalar_one_or_none()
+            if spool is not None:
+                slicer_filament, slicer_filament_name = await resolve_spool_preset(
+                    db,
+                    spool_id=spool.id,
+                    printer_model=model,
+                    nozzle_diameter=slot_nozzle.diameter,
+                    fallback_filament=spool.slicer_filament,
+                    fallback_name=spool.slicer_filament_name,
+                )
+
+    return {
+        "slicer_filament": slicer_filament,
+        "slicer_filament_name": slicer_filament_name,
+        "cali_idx": profile.cali_idx if profile else None,
+        "k_value": profile.k_value if profile else None,
+        "profile_name": profile.name if profile else None,
+        "extruder": slot_nozzle.extruder,
+        "nozzle_diameter": slot_nozzle.diameter,
+    }
+
+
 @router.post("/{printer_id}/slots/{ams_id}/{tray_id}/configure")
 async def configure_ams_slot(
     printer_id: int,
@@ -4169,13 +4270,12 @@ async def _apply_pa_after_refresh(printer_id: int, ams_id: int, slot_id: int):
             return
 
         # Compute nozzle/extruder once — used by both local and Spoolman lookup.
-        nozzle_diameter = "0.4"
-        if state.nozzles:
-            nd = state.nozzles[0].nozzle_diameter
-            if nd:
-                nozzle_diameter = nd
-
-        resolved_extruder = slot_extruder(ams_id, slot_id, state.ams_extruder_map, state.ams_switch_inlet)
+        # Shared with every other slot-configuring path (services.slot_nozzle),
+        # so the diameter this cascade filters on is the one the slot's own
+        # hotend actually has.
+        slot_nozzle = resolve_slot_nozzle(state, ams_id, slot_id, printer_manager.get_model(printer_id))
+        nozzle_diameter = slot_nozzle.diameter
+        resolved_extruder = slot_nozzle.extruder
 
         # 3-stage K-profile cascade: local SpoolKProfile → Spoolman SpoolmanKProfile
         # → live tray.cali_idx fallback. Pre-Phase-13 only handled the local path
@@ -4214,7 +4314,7 @@ async def _apply_pa_after_refresh(printer_id: int, ams_id: int, slot_id: int):
                     tag_filters.append(Spool.tag_uid == norm_tag)
                 if tag_filters:
                     tag_lookup = await db.execute(
-                        sa_select(Spool).options(selectinload(Spool.k_profiles)).where(or_(*tag_filters)).limit(1)
+                        select(Spool).options(selectinload(Spool.k_profiles)).where(or_(*tag_filters)).limit(1)
                     )
                     spool = tag_lookup.scalar_one_or_none()
                     if spool is not None:
@@ -4236,6 +4336,8 @@ async def _apply_pa_after_refresh(printer_id: int, ams_id: int, slot_id: int):
                 exact_kp = None
                 fallback_kp = None
                 for kp in spool.k_profiles:
+                    if not slot_nozzle.flow_matches(kp.nozzle_type):
+                        continue
                     if kp.printer_id != printer_id or kp.nozzle_diameter != nozzle_diameter or kp.cali_idx is None:
                         continue
                     if resolved_extruder is not None and kp.extruder is not None and kp.extruder == resolved_extruder:
@@ -4256,7 +4358,7 @@ async def _apply_pa_after_refresh(printer_id: int, ams_id: int, slot_id: int):
             # including the tag-based fallback above)
             if matching_cali_idx is None and spool is None:
                 sm_result = await db.execute(
-                    sa_select(SpoolmanSlotAssignment).where(
+                    select(SpoolmanSlotAssignment).where(
                         SpoolmanSlotAssignment.printer_id == printer_id,
                         SpoolmanSlotAssignment.ams_id == ams_id,
                         SpoolmanSlotAssignment.tray_id == slot_id,

+ 52 - 23
backend/app/api/routes/spoolman.py

@@ -22,6 +22,9 @@ from backend.app.models.spoolman_k_profile import SpoolmanKProfile
 from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 from backend.app.models.user import User
 from backend.app.services.printer_manager import printer_manager
+from backend.app.services.slicer_filament_resolver import resolve_slicer_filament
+from backend.app.services.slot_nozzle import resolve_slot_nozzle
+from backend.app.services.spool_filament_preset import resolve_spoolman_preset
 from backend.app.services.spoolman import (
     SpoolmanClientError,
     SpoolmanNotFoundError,
@@ -32,6 +35,7 @@ from backend.app.services.spoolman import (
 )
 from backend.app.utils.filament_ids import (
     GENERIC_FILAMENT_IDS,
+    filament_id_to_setting_id,
     normalize_slicer_filament,
 )
 from backend.app.utils.filament_types import nozzle_temp_range, printer_filament_type
@@ -956,33 +960,61 @@ async def link_spool(
                 if len(tray_color) == 6:
                     tray_color = tray_color + "FF"
 
+                # Pull printer state via printer_manager (mqtt_client.printer_state
+                # was a non-existent attribute — the hasattr check silently
+                # returned None, defeating every state-based lookup below).
+                state = printer_manager.get_status(p_id)
+                slot_nozzle = resolve_slot_nozzle(state, a_id, t_id, printer_manager.get_model(p_id))
+                nozzle_diameter = slot_nozzle.diameter
+
+                # Resolve the spool's own preset before falling back to a
+                # generic material id. This path used to skip that entirely and
+                # configure every linked slot as generic PLA/PETG, so a spool
+                # with a preset set in inventory lost it the moment it was
+                # linked by tag — the same defect #1713 fixed on the assign
+                # path, in the function next door. The per-model override
+                # cascade applies here for the same reason it does there: the
+                # preset is bound to a printer model.
+                slot_slicer_filament, slot_slicer_filament_name = await resolve_spoolman_preset(
+                    db,
+                    spoolman_spool_id=spool_id,
+                    printer_model=printer_manager.get_model(p_id),
+                    nozzle_diameter=nozzle_diameter,
+                    fallback_filament=mapped.get("slicer_filament"),
+                    fallback_name=mapped.get("slicer_filament_name"),
+                )
+                tray_info_idx, setting_id, sub_brand_override, type_override = await resolve_slicer_filament(
+                    db=db,
+                    current_user=None,
+                    slicer_filament=slot_slicer_filament,
+                    slicer_filament_name=slot_slicer_filament_name,
+                    material=material,
+                )
+                if sub_brand_override:
+                    tray_sub_brands = sub_brand_override
+                if type_override:
+                    tray_type = printer_filament_type(type_override)
+
                 # The spool's own wording is tried first and the reduced type
                 # only as a further fallback, so a material that already
                 # resolves keeps resolving to the same id: "PETG HF" has its
                 # own generic preset (GFG96) that reducing it to "PETG" would
                 # trade away for GFG99.
                 material_upper = material.upper().strip()
-                tray_info_idx = (
-                    GENERIC_FILAMENT_IDS.get(material_upper)
-                    or GENERIC_FILAMENT_IDS.get(material_upper.split("-")[0].split(" ")[0])
-                    or GENERIC_FILAMENT_IDS.get(tray_type.upper())
-                    or ""
-                )
-                setting_id = ""
+                if not tray_info_idx:
+                    tray_info_idx = (
+                        GENERIC_FILAMENT_IDS.get(material_upper)
+                        or GENERIC_FILAMENT_IDS.get(material_upper.split("-")[0].split(" ")[0])
+                        or GENERIC_FILAMENT_IDS.get(tray_type.upper())
+                        or ""
+                    )
+                if tray_info_idx and not setting_id:
+                    setting_id = filament_id_to_setting_id(tray_info_idx)
+
                 temp_defaults = nozzle_temp_range(material, tray_type)
                 temp_min = mapped.get("nozzle_temp_min") or temp_defaults[0]
                 temp_max = temp_defaults[1]
 
-                # Pull printer state via printer_manager (mqtt_client.printer_state
-                # was a non-existent attribute — the hasattr check silently
-                # returned None, defeating every state-based lookup below).
-                state = printer_manager.get_status(p_id)
-                nozzle_diameter = "0.4"
-                if state and state.nozzles:
-                    nd = state.nozzles[0].nozzle_diameter
-                    if nd:
-                        nozzle_diameter = nd
-
                 kp_result = await db.execute(
                     select(SpoolmanKProfile).where(
                         SpoolmanKProfile.spoolman_spool_id == spool_id,
@@ -990,12 +1022,7 @@ async def link_spool(
                     )
                 )
                 kp_rows = kp_result.scalars().all()
-                slot_extruder = None
-                if state and state.ams_extruder_map:
-                    if a_id == 255:
-                        slot_extruder = 1 - t_id
-                    else:
-                        slot_extruder = state.ams_extruder_map.get(str(a_id))
+                slot_extruder = slot_nozzle.extruder
 
                 # Prefer exact extruder match, fall back to extruder-agnostic kp
                 # for the same nozzle. Hard-skip on extruder mismatch silently
@@ -1006,6 +1033,8 @@ async def link_spool(
                 for kp in kp_rows:
                     if kp.nozzle_diameter != nozzle_diameter or kp.cali_idx is None:
                         continue
+                    if not slot_nozzle.flow_matches(kp.nozzle_type):
+                        continue
                     if slot_extruder is not None and kp.extruder is not None and kp.extruder == slot_extruder:
                         exact_kp = kp
                         break

+ 119 - 18
backend/app/api/routes/spoolman_inventory.py

@@ -38,10 +38,11 @@ from backend.app.core.websocket import ws_manager
 from backend.app.models.ams_label import AmsLabel
 from backend.app.models.printer import Printer
 from backend.app.models.settings import Settings
+from backend.app.models.spool_filament_preset import SpoolmanFilamentPreset
 from backend.app.models.spoolman_k_profile import SpoolmanKProfile
 from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 from backend.app.models.user import User
-from backend.app.schemas.spool import SpoolKProfileBase
+from backend.app.schemas.spool import SpoolFilamentPresetBase, SpoolKProfileBase
 from backend.app.schemas.spoolman import SpoolmanFilamentPatch, SpoolmanSlotAssignmentEnriched
 from backend.app.services.location_service import (
     enrich_spool_dicts_with_location_id,
@@ -50,6 +51,8 @@ from backend.app.services.location_service import (
 )
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.slicer_filament_resolver import resolve_slicer_filament
+from backend.app.services.slot_nozzle import resolve_slot_nozzle
+from backend.app.services.spool_filament_preset import resolve_spoolman_preset
 from backend.app.services.spoolman import (
     SpoolmanClient,
     SpoolmanClientError,
@@ -1507,6 +1510,33 @@ async def assign_spoolman_slot(
             if len(tray_color) == 6:
                 tray_color = tray_color + "FF"
 
+            # Printer state, read here rather than further down because the
+            # per-model preset override below needs the slot's nozzle
+            # diameter and the K-profile cascade further down needs the same
+            # value -- one read, so they cannot disagree. (The previous
+            # `mqtt_client.printer_state` access via hasattr always returned
+            # None -- the attribute is `state`, not `printer_state` -- so the
+            # K-profile cascade silently skipped state.kprofiles, defaulted
+            # nozzle_diameter to 0.4, and left slot_extruder unset.)
+            state = printer_manager.get_status(body.printer_id)
+            slot_nozzle = resolve_slot_nozzle(
+                state, body.ams_id, body.tray_id, printer_manager.get_model(body.printer_id)
+            )
+            nozzle_diameter = slot_nozzle.diameter
+
+            # Per-printer-model preset override, same cascade as internal
+            # mode: a cloud/Orca preset is bound to a model, so one stored
+            # preset per spool is wrong across two models. Returns Spoolman's
+            # own value when no override is set.
+            slot_slicer_filament, slot_slicer_filament_name = await resolve_spoolman_preset(
+                db,
+                spoolman_spool_id=body.spoolman_spool_id,
+                printer_model=printer_manager.get_model(body.printer_id),
+                nozzle_diameter=nozzle_diameter,
+                fallback_filament=mapped.get("slicer_filament"),
+                fallback_name=mapped.get("slicer_filament_name"),
+            )
+
             # #1713: resolve the spool's stored slicer_filament reference
             # (cloud preset, local preset, GF-prefix builtin, or numeric
             # LocalPreset id) to the printer-side tray_info_idx + setting_id.
@@ -1518,8 +1548,8 @@ async def assign_spoolman_slot(
             tray_info_idx, setting_id, sub_brand_override, type_override = await resolve_slicer_filament(
                 db=db,
                 current_user=current_user,
-                slicer_filament=mapped.get("slicer_filament"),
-                slicer_filament_name=mapped.get("slicer_filament_name"),
+                slicer_filament=slot_slicer_filament,
+                slicer_filament_name=slot_slicer_filament_name,
                 material=material,
             )
             if sub_brand_override:
@@ -1563,21 +1593,7 @@ async def assign_spoolman_slot(
             # None (the attribute is `state`, not `printer_state`), so the
             # K-profile cascade silently skipped state.kprofiles, defaulted
             # nozzle_diameter to 0.4, and left slot_extruder unset.
-            state = printer_manager.get_status(body.printer_id)
-            nozzle_diameter = "0.4"
-            if state and state.nozzles:
-                nd = state.nozzles[0].nozzle_diameter
-                if nd:
-                    nozzle_diameter = nd
-
-            slot_extruder = None
-            if state and state.ams_extruder_map:
-                if body.ams_id == 255:
-                    # External slots: ext-L (tray 0) → extruder 1, ext-R (tray 1) → extruder 0
-                    # tray_id 0→1, 1→0
-                    slot_extruder = 1 - body.tray_id
-                else:
-                    slot_extruder = state.ams_extruder_map.get(str(body.ams_id))
+            slot_extruder = slot_nozzle.extruder
 
             # Prefer exact extruder match, fall back to extruder-agnostic kp
             # for the same nozzle. Hard-skipping on mismatch silently dropped
@@ -1587,6 +1603,8 @@ async def assign_spoolman_slot(
             for kp in kp_rows:
                 if kp.nozzle_diameter != nozzle_diameter or kp.cali_idx is None:
                     continue
+                if not slot_nozzle.flow_matches(kp.nozzle_type):
+                    continue
                 if slot_extruder is not None and kp.extruder is not None and kp.extruder == slot_extruder:
                     exact_kp = kp
                     break
@@ -1820,6 +1838,89 @@ def _k_profile_to_dict(p: SpoolmanKProfile) -> dict:
     }
 
 
+def _filament_preset_to_dict(p: SpoolmanFilamentPreset) -> dict:
+    """Manually map SpoolmanFilamentPreset → SpoolFilamentPresetResponse-compatible dict."""
+    return {
+        "id": p.id,
+        "spool_id": p.spoolman_spool_id,
+        "printer_model": p.printer_model,
+        "nozzle_diameter": p.nozzle_diameter,
+        "slicer_filament": p.slicer_filament,
+        "slicer_filament_name": p.slicer_filament_name,
+        "created_at": p.created_at,
+    }
+
+
+@router.get("/spools/{spool_id}/filament-presets")
+async def get_spoolman_filament_presets(
+    spool_id: int = Path(..., gt=0),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+) -> list[dict]:
+    """Return all per-printer-model preset overrides for a Spoolman spool."""
+    await _get_client(db)
+    result = await db.execute(
+        select(SpoolmanFilamentPreset).where(SpoolmanFilamentPreset.spoolman_spool_id == spool_id)
+    )
+    return [_filament_preset_to_dict(p) for p in result.scalars().all()]
+
+
+@router.put("/spools/{spool_id}/filament-presets")
+async def save_spoolman_filament_presets(
+    spool_id: int = Path(..., gt=0),
+    presets: list[SpoolFilamentPresetBase] = Body(...),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+) -> list[dict]:
+    """Replace all per-printer-model preset overrides for a Spoolman spool."""
+    client = await _get_client(db)
+    async with _translate_spoolman_errors():
+        await client.get_spool(spool_id)
+
+    # Same as the internal route: reject a duplicated (model, diameter) before
+    # touching the stored rows, so a bad payload cannot clear what it fails to
+    # replace.
+    seen: set[tuple[str, str]] = set()
+    for preset in presets:
+        key = (preset.printer_model, preset.nozzle_diameter)
+        if key in seen:
+            raise HTTPException(
+                422,
+                f"Duplicate override for model {preset.printer_model!r} nozzle {preset.nozzle_diameter or 'any'!r}",
+            )
+        seen.add(key)
+
+    saved: list[SpoolmanFilamentPreset] = []
+    try:
+        await db.execute(delete(SpoolmanFilamentPreset).where(SpoolmanFilamentPreset.spoolman_spool_id == spool_id))
+        await db.flush()
+        for preset in presets:
+            obj = SpoolmanFilamentPreset(
+                spoolman_spool_id=spool_id,
+                printer_model=preset.printer_model,
+                nozzle_diameter=preset.nozzle_diameter,
+                slicer_filament=preset.slicer_filament,
+                slicer_filament_name=preset.slicer_filament_name,
+            )
+            db.add(obj)
+            saved.append(obj)
+        await db.commit()
+    except IntegrityError as exc:
+        await db.rollback()
+        raise HTTPException(422, "Duplicate or invalid preset override (check model and nozzle uniqueness)") from exc
+    except HTTPException:
+        raise
+    except Exception as exc:
+        await db.rollback()
+        logger.error("Filament preset save for spool %d failed: %s", spool_id, exc)
+        raise HTTPException(500, "Failed to save filament presets") from exc
+
+    for obj in saved:
+        await db.refresh(obj)
+
+    return [_filament_preset_to_dict(p) for p in saved]
+
+
 def _normalize_filament(raw: dict) -> NormalizedFilament | None:
     """Normalise a raw Spoolman filament dict for the frontend catalog picker.
 

+ 1 - 0
backend/app/core/database.py

@@ -330,6 +330,7 @@ async def init_db():
         spool,
         spool_assignment,
         spool_catalog,
+        spool_filament_preset,
         spool_k_profile,
         spool_usage_history,
         spoolbuddy_device,

+ 26 - 16
backend/app/main.py

@@ -128,10 +128,16 @@ from backend.app.services.printer_manager import (
     resolve_plate_id,
 )
 from backend.app.services.slot_kprofile import find_slot_kprofile_for_extruder
+from backend.app.services.slot_nozzle import (
+    nozzle_diameter_for_extruder,
+    nozzle_flow_for_extruder,
+    resolve_slot_nozzle,
+)
 from backend.app.services.smart_plug_manager import smart_plug_manager
 from backend.app.services.spool_assignment_notifications import (
     notify_missing_spool_assignments_on_print_start,
 )
+from backend.app.services.spool_filament_preset import printer_safe_filament_id
 from backend.app.services.spoolman import close_spoolman_client, get_spoolman_client, init_spoolman_client
 from backend.app.services.spoolman_tracking import (
     cleanup_tracking as _cleanup_spoolman_tracking,
@@ -141,7 +147,7 @@ from backend.app.services.spoolman_tracking import (
 from backend.app.services.tasmota import tasmota_service
 from backend.app.utils.ams_drying import is_drying_active, temperature_alarm_suppressed
 from backend.app.utils.filament_types import printer_filament_type
-from backend.app.utils.fts_routing import extruder_for_inlet, slot_extruder as resolve_slot_extruder
+from backend.app.utils.fts_routing import extruder_for_inlet
 from backend.app.utils.local_time import utcnow_naive
 from backend.app.utils.print_jobs import is_internal_printer_job
 
@@ -1892,9 +1898,11 @@ async def on_fts_inlet_change(printer_id: int, ams_id: int, inlet: str):
     if not client or not state or not state.raw_data:
         return
 
-    nozzle_diameter = "0.4"
-    if state.nozzles and state.nozzles[0].nozzle_diameter:
-        nozzle_diameter = state.nozzles[0].nozzle_diameter
+    # The nozzle the AMS now feeds -- the diameter of the TARGET extruder, not
+    # of nozzle 0. On a machine with two sizes fitted, moving the inlet changes
+    # the nozzle width, which changes both the K profile to select and the
+    # preset the slot should carry.
+    nozzle_diameter = nozzle_diameter_for_extruder(state, target_extruder, printer_manager.get_model(printer_id))
 
     ams_raw = state.raw_data.get("ams")
     ams_list = ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
@@ -1911,7 +1919,14 @@ async def on_fts_inlet_change(printer_id: int, ams_id: int, inlet: str):
                 current_idx = tray.get("cali_idx")
 
                 profile = await find_slot_kprofile_for_extruder(
-                    db, printer_id, ams_id, tray_id, target_extruder, nozzle_diameter
+                    db,
+                    printer_id,
+                    ams_id,
+                    tray_id,
+                    target_extruder,
+                    nozzle_diameter,
+                    printer_manager.get_model(printer_id),
+                    nozzle_flow_for_extruder(state, target_extruder, printer_manager.get_model(printer_id)),
                 )
                 if profile is None or profile.cali_idx is None:
                     continue
@@ -1935,7 +1950,7 @@ async def on_fts_inlet_change(printer_id: int, ams_id: int, inlet: str):
                     ams_id=ams_id,
                     tray_id=tray_id,
                     cali_idx=profile.cali_idx,
-                    filament_id=profile.filament_id or tray.get("tray_info_idx", "") or "",
+                    filament_id=printer_safe_filament_id(profile.filament_id, tray.get("tray_info_idx", "")),
                     nozzle_diameter=nozzle_diameter,
                 )
     except Exception as e:
@@ -2364,17 +2379,11 @@ async def on_ams_change(printer_id: int, ams_data: list):
                                     and spool.k_profiles
                                 ):
                                     state = printer_manager.get_status(printer_id)
-                                    nozzle_diameter = "0.4"
-                                    if state and state.nozzles:
-                                        nd = state.nozzles[0].nozzle_diameter
-                                        if nd:
-                                            nozzle_diameter = nd
-                                    slot_extruder = resolve_slot_extruder(
-                                        ams_id,
-                                        tray_id,
-                                        state.ams_extruder_map if state else None,
-                                        state.ams_switch_inlet if state else None,
+                                    slot_nozzle = resolve_slot_nozzle(
+                                        state, ams_id, tray_id, printer_manager.get_model(printer_id)
                                     )
+                                    nozzle_diameter = slot_nozzle.diameter
+                                    slot_extruder = slot_nozzle.extruder
                                     # Prefer exact extruder match, fall back to
                                     # extruder-agnostic kp for the same printer +
                                     # nozzle. Avoids hard-skipping when the AMS is
@@ -2386,6 +2395,7 @@ async def on_ams_change(printer_id: int, ams_data: list):
                                             kp.printer_id != printer_id
                                             or kp.nozzle_diameter != nozzle_diameter
                                             or kp.cali_idx is None
+                                            or not slot_nozzle.flow_matches(kp.nozzle_type)
                                         ):
                                             continue
                                         if (

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

@@ -34,6 +34,7 @@ from backend.app.models.sponsor_toast_state import SponsorToastState
 from backend.app.models.spool import Spool
 from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.models.spool_catalog import SpoolCatalogEntry
+from backend.app.models.spool_filament_preset import SpoolFilamentPreset, SpoolmanFilamentPreset
 from backend.app.models.spool_k_profile import SpoolKProfile
 from backend.app.models.spool_usage_history import SpoolUsageHistory
 from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
@@ -82,7 +83,9 @@ __all__ = [
     "PipelineRun",
     "SlicerPipeline",
     "Spool",
+    "SpoolFilamentPreset",
     "SpoolKProfile",
+    "SpoolmanFilamentPreset",
     "SpoolAssignment",
     "SpoolCatalogEntry",
     "SpoolUsageHistory",

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

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

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

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

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

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

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

@@ -853,7 +853,12 @@ class PrinterState:
     wifi_signal: int | None = None  # WiFi signal strength in dBm
     wired_network: bool = False  # Ethernet connection detected (home_flag bit 18)
     door_open: bool = False  # Enclosure door open (home_flag bit 23; models with a door sensor: X1/X1C/X1E/X2D/P2S/H2*)
-    # Nozzle hardware info (for dual nozzle printers, index 0 = left, 1 = right)
+    # Nozzle hardware info. Indexed by EXTRUDER id: [0] is the RIGHT hotend and
+    # [1] the left, measured 2026-08-27 on an H2D fitted with 0.4 left / 0.6
+    # right. (The legacy parser below writes left -> [0], but it only ever runs
+    # for single-nozzle printers -- every dual-nozzle model reports
+    # device.nozzle.info instead.) Read it through services.slot_nozzle rather
+    # than indexing it directly.
     nozzles: list = field(default_factory=lambda: [NozzleInfo(), NozzleInfo()])
     # AI detection and print options
     print_options: PrintOptions = field(default_factory=PrintOptions)

+ 48 - 6
backend/app/services/slot_kprofile.py

@@ -23,6 +23,7 @@ from backend.app.models.spool_k_profile import SpoolKProfile
 from backend.app.models.spoolman_k_profile import SpoolmanKProfile
 from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 from backend.app.services.inventory_mode import spoolman_owns_assignments
+from backend.app.services.spool_filament_preset import resolve_spool_preset, resolve_spoolman_preset
 
 
 @dataclass(frozen=True)
@@ -38,6 +39,13 @@ class SlotKProfile:
     filament_id: str | None
 
 
+def _flow_applies(stored_flow: str | None, fitted_flow: str | None) -> bool:
+    """``SlotNozzle.flow_matches`` for callers that hold only the two strings."""
+    from backend.app.services.slot_nozzle import SlotNozzle
+
+    return SlotNozzle(extruder=None, diameter="", flow=fitted_flow).flow_matches(stored_flow)
+
+
 async def find_slot_kprofile_for_extruder(
     db: AsyncSession,
     printer_id: int,
@@ -45,6 +53,8 @@ async def find_slot_kprofile_for_extruder(
     tray_id: int,
     extruder: int,
     nozzle_diameter: str,
+    printer_model: str | None = None,
+    flow: str | None = None,
 ) -> SlotKProfile | None:
     """Stored profile for whatever is in this slot, calibrated for ``extruder``.
 
@@ -89,16 +99,35 @@ async def find_slot_kprofile_for_extruder(
                 )
             )
             .scalars()
-            .first()
+            .all()
         )
+        # Flow is filtered here rather than in SQL: a stored NULL matches any
+        # fitted nozzle (see SlotNozzle.flow_matches), which is not an equality
+        # test and would need an OR IS NULL that reads worse than this.
+        profile = next((p for p in profile if _flow_applies(p.nozzle_type, flow)), None)
         if profile is not None:
             spool = (await db.execute(select(Spool).where(Spool.id == assignment.spool_id))).scalar_one_or_none()
+            # The preset this profile was calibrated under, through the
+            # per-printer-model cascade: a spool can carry a different preset
+            # per model, and extrusion_cali_sel has to name the one the printer
+            # will actually see in the slot. Falls back to the spool's own
+            # value when the caller cannot say which model this is.
+            filament_id = spool.slicer_filament if spool else None
+            if spool is not None and printer_model:
+                filament_id, _ = await resolve_spool_preset(
+                    db,
+                    spool_id=spool.id,
+                    printer_model=printer_model,
+                    nozzle_diameter=nozzle_diameter,
+                    fallback_filament=spool.slicer_filament,
+                    fallback_name=spool.slicer_filament_name,
+                )
             return SlotKProfile(
                 cali_idx=profile.cali_idx,
                 k_value=profile.k_value,
                 name=profile.name,
                 extruder=profile.extruder,
-                filament_id=(spool.slicer_filament if spool else None),
+                filament_id=filament_id,
             )
         # A known spool with no profile for this nozzle is a deliberate stop:
         # falling through to Spoolman would answer for a different spool.
@@ -131,17 +160,30 @@ async def find_slot_kprofile_for_extruder(
             )
         )
         .scalars()
-        .first()
+        .all()
     )
+    sm_profile = next((p for p in sm_profile if _flow_applies(p.nozzle_type, flow)), None)
     if sm_profile is None:
         return None
 
-    # Spoolman rows carry no slicer preset; the caller falls back to the tray's
-    # own tray_info_idx for filament_id.
+    # A Spoolman K row carries no preset of its own, but the spool can still
+    # have a per-model override stored locally -- that is the same table the
+    # Spoolman assign path writes. Without a model to key on there is nothing
+    # to resolve and the caller falls back to the tray's own tray_info_idx.
+    sm_filament_id = None
+    if printer_model:
+        sm_filament_id, _ = await resolve_spoolman_preset(
+            db,
+            spoolman_spool_id=sm_assignment.spoolman_spool_id,
+            printer_model=printer_model,
+            nozzle_diameter=nozzle_diameter,
+            fallback_filament=None,
+            fallback_name=None,
+        )
     return SlotKProfile(
         cali_idx=sm_profile.cali_idx,
         k_value=sm_profile.k_value,
         name=sm_profile.name,
         extruder=sm_profile.extruder,
-        filament_id=None,
+        filament_id=sm_filament_id,
     )

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

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

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

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

+ 39 - 12
backend/app/services/spool_tag_matcher.py

@@ -9,6 +9,8 @@ from sqlalchemy.orm import selectinload
 from backend.app.models.spool import Spool
 from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.schemas.spool import normalize_effect_type
+from backend.app.services.slot_nozzle import resolve_slot_nozzle
+from backend.app.services.spool_filament_preset import printer_safe_filament_id, resolve_spool_preset
 from backend.app.utils.tag_normalization import (
     normalize_tag_uid as _normalize_tag_uid,
     normalize_tray_uuid as _normalize_tray_uuid,
@@ -566,24 +568,49 @@ async def auto_assign_spool(
     try:
         client = printer_manager.get_client(printer_id)
         if client:
-            # Apply K-profile if available
-            nozzle_diameter = "0.4"
-            if state and state.nozzles:
-                nd = state.nozzles[0].nozzle_diameter
-                if nd:
-                    nozzle_diameter = nd
-
-            matching_kp = None
+            # Which nozzle this slot feeds, resolved the same way every other
+            # slot-configuring path resolves it (services.slot_nozzle).
+            slot_nozzle = resolve_slot_nozzle(state, ams_id, tray_id, printer_manager.get_model(printer_id))
+            nozzle_diameter = slot_nozzle.diameter
+
+            # Prefer the profile calibrated for THIS hotend, falling back to one
+            # stored for the same nozzle size on the other. Before this the
+            # first row matching (printer, diameter) won outright with no
+            # extruder test at all -- on a dual-nozzle printer that is a coin
+            # toss between the two hotends, on the path that fires unattended
+            # every time an RFID spool is loaded.
+            exact_kp = None
+            fallback_kp = None
             for kp in spool.k_profiles:
-                if kp.printer_id == printer_id and kp.nozzle_diameter == nozzle_diameter:
-                    matching_kp = kp
+                if kp.printer_id != printer_id or kp.nozzle_diameter != nozzle_diameter:
+                    continue
+                if not slot_nozzle.flow_matches(kp.nozzle_type):
+                    continue
+                if slot_nozzle.extruder is not None and kp.extruder == slot_nozzle.extruder:
+                    exact_kp = kp
                     break
+                if fallback_kp is None:
+                    fallback_kp = kp
+            matching_kp = exact_kp or fallback_kp
+
+            # The id sent with extrusion_cali_sel has to name the preset the
+            # profile was calibrated under, and that preset can differ per
+            # printer model -- so it comes from the same cascade the assign
+            # paths use rather than straight off the spool.
+            model_filament, _ = await resolve_spool_preset(
+                db,
+                spool_id=spool.id,
+                printer_model=printer_manager.get_model(printer_id),
+                nozzle_diameter=nozzle_diameter,
+                fallback_filament=spool.slicer_filament,
+                fallback_name=spool.slicer_filament_name,
+            )
 
             if matching_kp and matching_kp.cali_idx is not None:
                 # The filament_id in extrusion_cali_sel must match the filament preset
                 # under which the K-profile was calibrated. Use spool.slicer_filament
                 # (the preset assigned in inventory), falling back to tray's RFID value.
-                cali_filament_id = spool.slicer_filament or tray_info_idx or ""
+                cali_filament_id = printer_safe_filament_id(model_filament, spool.slicer_filament, tray_info_idx)
                 client.extrusion_cali_sel(
                     ams_id=ams_id,
                     tray_id=tray_id,
@@ -610,7 +637,7 @@ async def auto_assign_spool(
                 # so the printer keeps its existing calibration selection.
                 live_cali_idx = tray.get("cali_idx")
                 if live_cali_idx is not None and live_cali_idx >= 0:
-                    cali_filament_id = spool.slicer_filament or tray_info_idx or ""
+                    cali_filament_id = printer_safe_filament_id(model_filament, spool.slicer_filament, tray_info_idx)
                     client.extrusion_cali_sel(
                         ams_id=ams_id,
                         tray_id=tray_id,

+ 1 - 0
backend/tests/conftest.py

@@ -244,6 +244,7 @@ async def test_engine():
         spool,
         spool_assignment,
         spool_catalog,
+        spool_filament_preset,
         spool_k_profile,
         spool_usage_history,
         spoolbuddy_device,

+ 168 - 0
backend/tests/integration/test_slot_spool_defaults.py

@@ -0,0 +1,168 @@
+"""GET /printers/{id}/slots/{ams}/{tray}/spool-defaults
+
+What the Configure AMS Slot dialog opens with. The slot usually already holds
+an assigned spool, and that spool carries a filament preset per printer model
+and a K profile per hotend -- the values the user set for exactly this
+situation. Before this endpoint the dialog defaulted to the slot's last manual
+configuration or the tray's RFID data and ignored them.
+
+Everything is resolved for the nozzle THIS slot feeds, so the answer differs
+between the two hotends of a dual-nozzle machine.
+"""
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+URL = "/api/v1/printers/{pid}/slots/{ams}/{tray}/spool-defaults"
+
+RIGHT, LEFT = 0, 1
+
+
+class _Nozzle:
+    def __init__(self, diameter):
+        self.nozzle_diameter = diameter
+
+
+class _State:
+    """Dual-nozzle, 0.4 on the right and 0.2 on the left, AMS 0 -> left."""
+
+    def __init__(self, diameters=("0.4", "0.2")):
+        self.nozzles = [_Nozzle(d) for d in diameters]
+        self.ams_extruder_map = {"0": LEFT, "1": RIGHT}
+        self.ams_switch_inlet = None
+        self.raw_data = {}
+
+
+@pytest.fixture
+def dual_nozzle_printer_state():
+    with patch("backend.app.api.routes.printers.printer_manager") as manager:
+        manager.get_status = MagicMock(return_value=_State())
+        manager.get_model = MagicMock(return_value="H2D")
+        yield manager
+
+
+@pytest.fixture
+async def assigned_spool(db_session, printer_factory):
+    """A spool in AMS 0 tray 0, with a per-model preset and both hotends calibrated."""
+    from backend.app.models.spool import Spool
+    from backend.app.models.spool_assignment import SpoolAssignment
+    from backend.app.models.spool_filament_preset import SpoolFilamentPreset
+    from backend.app.models.spool_k_profile import SpoolKProfile
+
+    printer = await printer_factory(model="H2D")
+    spool = Spool(
+        brand="Bambu",
+        material="PLA",
+        color_name="Black",
+        slicer_filament="GFSA00",
+        slicer_filament_name="Bambu PLA Basic @BBL X1C",
+    )
+    db_session.add(spool)
+    await db_session.commit()
+    await db_session.refresh(spool)
+
+    db_session.add(SpoolAssignment(spool_id=spool.id, printer_id=printer.id, ams_id=0, tray_id=0))
+    db_session.add(
+        SpoolFilamentPreset(
+            spool_id=spool.id,
+            printer_model="H2D",
+            nozzle_diameter="0.2",
+            slicer_filament="GFSA21",
+            slicer_filament_name="Bambu PLA Basic @BBL H2D 0.2 nozzle",
+        )
+    )
+    db_session.add_all(
+        [
+            SpoolKProfile(
+                spool_id=spool.id,
+                printer_id=printer.id,
+                extruder=LEFT,
+                nozzle_diameter="0.2",
+                k_value=0.018,
+                cali_idx=16,
+                name="PLA left",
+            ),
+            SpoolKProfile(
+                spool_id=spool.id,
+                printer_id=printer.id,
+                extruder=RIGHT,
+                nozzle_diameter="0.4",
+                k_value=0.020,
+                cali_idx=15,
+                name="PLA right",
+            ),
+        ]
+    )
+    await db_session.commit()
+    return printer, spool
+
+
+@pytest.mark.integration
+class TestSlotSpoolDefaults:
+    @pytest.mark.asyncio
+    async def test_answers_for_the_hotend_this_slot_feeds(
+        self, async_client: AsyncClient, assigned_spool, dual_nozzle_printer_state
+    ):
+        printer, _ = assigned_spool
+        response = await async_client.get(URL.format(pid=printer.id, ams=0, tray=0))
+        assert response.status_code == 200, response.text
+        body = response.json()
+
+        # AMS 0 feeds the LEFT hotend, which has the 0.2 fitted.
+        assert body["extruder"] == LEFT
+        assert body["nozzle_diameter"] == "0.2"
+        # So the 0.2 preset override, not the spool's own X1C one...
+        assert body["slicer_filament"] == "GFSA21"
+        # ...and the profile calibrated on that hotend, not the other's.
+        assert body["cali_idx"] == 16
+        assert body["k_value"] == pytest.approx(0.018)
+        assert body["profile_name"] == "PLA left"
+
+    @pytest.mark.asyncio
+    async def test_a_slot_with_no_spool_answers_nulls_not_404(
+        self, async_client: AsyncClient, assigned_spool, dual_nozzle_printer_state
+    ):
+        """ "Nothing configured" is an ordinary answer -- the dialog falls back
+        to what it did before rather than treating it as an error."""
+        printer, _ = assigned_spool
+        response = await async_client.get(URL.format(pid=printer.id, ams=1, tray=3))
+        assert response.status_code == 200
+        body = response.json()
+        assert body["slicer_filament"] is None
+        assert body["cali_idx"] is None
+        # The nozzle is still resolved -- AMS 1 is the right hotend.
+        assert body["extruder"] == RIGHT
+        assert body["nozzle_diameter"] == "0.4"
+
+    @pytest.mark.asyncio
+    async def test_falls_back_to_the_spools_own_preset_without_an_override(
+        self, async_client: AsyncClient, assigned_spool, dual_nozzle_printer_state, db_session
+    ):
+        from backend.app.models.spool_assignment import SpoolAssignment
+
+        printer, spool = assigned_spool
+        # Same spool in a slot on the RIGHT hotend, which has no 0.4 override.
+        db_session.add(SpoolAssignment(spool_id=spool.id, printer_id=printer.id, ams_id=1, tray_id=0))
+        await db_session.commit()
+
+        body = (await async_client.get(URL.format(pid=printer.id, ams=1, tray=0))).json()
+
+        assert body["slicer_filament"] == "GFSA00"
+        assert body["cali_idx"] == 15
+
+    @pytest.mark.asyncio
+    async def test_an_offline_printer_still_answers(self, async_client: AsyncClient, assigned_spool):
+        """Opening the dialog on a disconnected printer must not 500 -- it just
+        cannot say which hotend the slot feeds."""
+        printer, _ = assigned_spool
+        with patch("backend.app.api.routes.printers.printer_manager") as manager:
+            manager.get_status = MagicMock(return_value=None)
+            manager.get_model = MagicMock(return_value="H2D")
+            response = await async_client.get(URL.format(pid=printer.id, ams=0, tray=0))
+
+        assert response.status_code == 200
+        body = response.json()
+        assert body["extruder"] is None
+        assert body["nozzle_diameter"] == "0.4"

+ 234 - 0
backend/tests/integration/test_spool_filament_preset_endpoints.py

@@ -0,0 +1,234 @@
+"""Endpoints for the per-printer-model filament preset overrides.
+
+    GET  /api/v1/inventory/spools/{id}/filament-presets
+    PUT  /api/v1/inventory/spools/{id}/filament-presets
+    GET  /api/v1/spoolman/inventory/spools/{id}/filament-presets
+    PUT  /api/v1/spoolman/inventory/spools/{id}/filament-presets
+
+Both PUTs replace the whole set, matching the K-profile endpoints beside them:
+the spool form always holds the complete list, and an empty body is how the
+user clears every override back to the spool's own preset.
+
+The case worth having a test for is the duplicate: (model, diameter) is
+UNIQUE, so a payload naming one twice has to be refused -- and refused
+*before* the existing rows are deleted, or a rejected save takes the user's
+overrides with it.
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+SAMPLE_SPOOL = {
+    "id": 7,
+    "filament": {
+        "id": 1,
+        "name": "PLA Basic",
+        "material": "PLA",
+        "weight": 1000,
+        "color_hex": "303030",
+        "vendor": {"id": 1, "name": "Bambu"},
+    },
+    "remaining_weight": 800.0,
+    "used_weight": 200.0,
+    "location": None,
+    "comment": None,
+    "first_used": None,
+    "last_used": None,
+    "registered": "2024-01-01T00:00:00+00:00",
+    "archived": False,
+    "price": None,
+    "extra": {},
+}
+
+INTERNAL = "/api/v1/inventory/spools"
+SPOOLMAN = "/api/v1/spoolman/inventory/spools"
+
+
+@pytest.fixture
+async def spool(db_session):
+    from backend.app.models.spool import Spool
+
+    row = Spool(
+        brand="Bambu",
+        material="PLA",
+        color_name="Charcoal",
+        slicer_filament="GFSA00",
+        slicer_filament_name="Bambu PLA Basic @BBL X1C",
+    )
+    db_session.add(row)
+    await db_session.commit()
+    await db_session.refresh(row)
+    return row
+
+
+@pytest.fixture
+async def spoolman_settings(db_session):
+    from backend.app.models.settings import Settings
+
+    db_session.add(Settings(key="spoolman_enabled", value="true"))
+    db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
+    await db_session.commit()
+
+
+@pytest.fixture
+def mock_spoolman_client():
+    client = MagicMock()
+    client.base_url = "http://localhost:7912"
+    client.health_check = AsyncMock(return_value=True)
+    client.get_spool = AsyncMock(return_value=SAMPLE_SPOOL)
+
+    with patch(
+        "backend.app.api.routes.spoolman_inventory._get_client",
+        AsyncMock(return_value=client),
+    ):
+        yield client
+
+
+def _preset(model, diameter="", code="GFSA09", name="Bambu PLA Basic @BBL H2C"):
+    return {
+        "printer_model": model,
+        "nozzle_diameter": diameter,
+        "slicer_filament": code,
+        "slicer_filament_name": name,
+    }
+
+
+@pytest.mark.integration
+class TestInternalInventory:
+    @pytest.mark.asyncio
+    async def test_empty_by_default(self, async_client: AsyncClient, spool):
+        response = await async_client.get(f"{INTERNAL}/{spool.id}/filament-presets")
+        assert response.status_code == 200
+        assert response.json() == []
+
+    @pytest.mark.asyncio
+    async def test_put_then_get_round_trips(self, async_client: AsyncClient, spool):
+        response = await async_client.put(
+            f"{INTERNAL}/{spool.id}/filament-presets",
+            json=[_preset("H2C"), _preset("A1 mini", "0.2", "GFSA21", "PLA @A1M 0.2 nozzle")],
+        )
+        assert response.status_code == 200, response.text
+
+        rows = (await async_client.get(f"{INTERNAL}/{spool.id}/filament-presets")).json()
+        assert len(rows) == 2
+        by_model = {r["printer_model"]: r for r in rows}
+        assert by_model["H2C"]["nozzle_diameter"] == ""
+        assert by_model["H2C"]["slicer_filament"] == "GFSA09"
+        assert by_model["A1 mini"]["nozzle_diameter"] == "0.2"
+        assert by_model["A1 mini"]["slicer_filament"] == "GFSA21"
+        assert all(r["spool_id"] == spool.id for r in rows)
+
+    @pytest.mark.asyncio
+    async def test_put_replaces_rather_than_appends(self, async_client: AsyncClient, spool):
+        await async_client.put(f"{INTERNAL}/{spool.id}/filament-presets", json=[_preset("H2C")])
+        await async_client.put(f"{INTERNAL}/{spool.id}/filament-presets", json=[_preset("X1C")])
+
+        rows = (await async_client.get(f"{INTERNAL}/{spool.id}/filament-presets")).json()
+        assert [r["printer_model"] for r in rows] == ["X1C"]
+
+    @pytest.mark.asyncio
+    async def test_empty_body_clears_every_override(self, async_client: AsyncClient, spool):
+        await async_client.put(f"{INTERNAL}/{spool.id}/filament-presets", json=[_preset("H2C")])
+
+        response = await async_client.put(f"{INTERNAL}/{spool.id}/filament-presets", json=[])
+        assert response.status_code == 200
+        assert (await async_client.get(f"{INTERNAL}/{spool.id}/filament-presets")).json() == []
+
+    @pytest.mark.asyncio
+    async def test_replacing_the_same_key_does_not_trip_the_unique_constraint(self, async_client: AsyncClient, spool):
+        """Deletes and inserts land in one transaction, and SQLAlchemy is free
+        to order the INSERTs first. Re-saving the same (model, diameter) with a
+        new preset is the ordinary case -- the user changed their pick."""
+        await async_client.put(f"{INTERNAL}/{spool.id}/filament-presets", json=[_preset("H2C")])
+
+        response = await async_client.put(
+            f"{INTERNAL}/{spool.id}/filament-presets",
+            json=[_preset("H2C", "", "GFSA11", "Bambu PLA Matte @BBL H2C")],
+        )
+        assert response.status_code == 200, response.text
+
+        rows = (await async_client.get(f"{INTERNAL}/{spool.id}/filament-presets")).json()
+        assert len(rows) == 1
+        assert rows[0]["slicer_filament"] == "GFSA11"
+
+    @pytest.mark.asyncio
+    async def test_duplicate_key_is_rejected_without_losing_the_stored_rows(self, async_client: AsyncClient, spool):
+        await async_client.put(f"{INTERNAL}/{spool.id}/filament-presets", json=[_preset("H2C")])
+
+        response = await async_client.put(
+            f"{INTERNAL}/{spool.id}/filament-presets",
+            json=[_preset("X1C", "", "GFSA01"), _preset("X1C", "", "GFSA02")],
+        )
+        assert response.status_code == 422
+
+        # The rejected save must not have taken the existing override with it.
+        rows = (await async_client.get(f"{INTERNAL}/{spool.id}/filament-presets")).json()
+        assert [r["printer_model"] for r in rows] == ["H2C"]
+
+    @pytest.mark.asyncio
+    async def test_unknown_spool_is_404(self, async_client: AsyncClient):
+        response = await async_client.put(f"{INTERNAL}/999999/filament-presets", json=[_preset("H2C")])
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    async def test_blank_model_is_rejected(self, async_client: AsyncClient, spool):
+        """An empty printer_model would store a row the cascade can never
+        match, since it refuses to resolve without a model."""
+        response = await async_client.put(f"{INTERNAL}/{spool.id}/filament-presets", json=[_preset("")])
+        assert response.status_code == 422
+
+
+@pytest.mark.integration
+class TestSpoolmanInventory:
+    @pytest.mark.asyncio
+    async def test_empty_by_default(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
+        response = await async_client.get(f"{SPOOLMAN}/7/filament-presets")
+        assert response.status_code == 200
+        assert response.json() == []
+
+    @pytest.mark.asyncio
+    async def test_put_then_get_round_trips(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
+        response = await async_client.put(
+            f"{SPOOLMAN}/7/filament-presets",
+            json=[_preset("H2C"), _preset("H2C", "0.2", "GFSA10", "PLA @H2C 0.2 nozzle")],
+        )
+        assert response.status_code == 200, response.text
+
+        rows = (await async_client.get(f"{SPOOLMAN}/7/filament-presets")).json()
+        assert len(rows) == 2
+        assert all(r["spool_id"] == 7 for r in rows)
+        assert {r["nozzle_diameter"] for r in rows} == {"", "0.2"}
+
+    @pytest.mark.asyncio
+    async def test_put_replaces_rather_than_appends(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        await async_client.put(f"{SPOOLMAN}/7/filament-presets", json=[_preset("H2C")])
+        await async_client.put(f"{SPOOLMAN}/7/filament-presets", json=[_preset("X1C")])
+
+        rows = (await async_client.get(f"{SPOOLMAN}/7/filament-presets")).json()
+        assert [r["printer_model"] for r in rows] == ["X1C"]
+
+    @pytest.mark.asyncio
+    async def test_duplicate_key_is_rejected_without_losing_the_stored_rows(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        await async_client.put(f"{SPOOLMAN}/7/filament-presets", json=[_preset("H2C")])
+
+        response = await async_client.put(
+            f"{SPOOLMAN}/7/filament-presets",
+            json=[_preset("X1C", "", "GFSA01"), _preset("X1C", "", "GFSA02")],
+        )
+        assert response.status_code == 422
+
+        rows = (await async_client.get(f"{SPOOLMAN}/7/filament-presets")).json()
+        assert [r["printer_model"] for r in rows] == ["H2C"]
+
+    @pytest.mark.asyncio
+    async def test_one_spools_overrides_do_not_leak_into_another(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        await async_client.put(f"{SPOOLMAN}/7/filament-presets", json=[_preset("H2C")])
+        assert (await async_client.get(f"{SPOOLMAN}/8/filament-presets")).json() == []

+ 179 - 0
backend/tests/unit/test_assign_uses_model_preset_override.py

@@ -0,0 +1,179 @@
+"""The per-model override has to reach the slot, not just the database.
+
+``apply_spool_to_slot_via_mqtt`` is where a spool becomes an AMS slot
+configuration, and the preset it resolves is what the printer (and the slicer
+reading the slot back) ends up with. These drive that function and assert on
+what it handed to ``resolve_slicer_filament``, which is the last point the
+preset is still a stored reference rather than a printer-side id.
+
+The regression they guard is the whole point of the feature: a spool whose
+single ``slicer_filament`` is an ``@BBL X1C`` preset, assigned on an H2C,
+previously configured that H2C slot with the X1C preset.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from sqlalchemy import select
+from sqlalchemy.orm import selectinload
+
+from backend.app.models.spool import Spool
+from backend.app.models.spool_filament_preset import SpoolFilamentPreset
+
+pytestmark = pytest.mark.asyncio
+
+SPOOL_DEFAULT = "GFSA00"
+SPOOL_DEFAULT_NAME = "Bambu PLA Basic @BBL X1C"
+
+
+class _Nozzle:
+    def __init__(self, diameter: str):
+        self.nozzle_diameter = diameter
+        self.nozzle_type = "HH01"
+
+
+class _State:
+    """Just enough live printer state for the assign path."""
+
+    def __init__(self, diameter: str = "0.4"):
+        self.nozzles = [_Nozzle(diameter), _Nozzle(diameter)]
+        self.ams_extruder_map = {}
+        self.kprofiles = []
+        self.raw_data = {}
+
+
+async def _spool(db_session) -> Spool:
+    spool = Spool(
+        brand="Bambu",
+        material="PLA",
+        color_name="Charcoal",
+        slicer_filament=SPOOL_DEFAULT,
+        slicer_filament_name=SPOOL_DEFAULT_NAME,
+    )
+    db_session.add(spool)
+    await db_session.commit()
+    # Loaded the way every production caller loads it: k_profiles is a lazy
+    # relationship the assign path walks, and an async session cannot resolve
+    # it mid-call.
+    result = await db_session.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool.id))
+    return result.scalar_one()
+
+
+async def _assign(db_session, spool, printer, *, model: str, diameter: str = "0.4"):
+    """Run the assign path far enough to capture the resolved preset.
+
+    Returns the kwargs ``resolve_slicer_filament`` was called with. Everything
+    downstream of it is stubbed: this asserts which preset was chosen, not how
+    the MQTT payload is built (that is covered elsewhere).
+    """
+    from backend.app.api.routes import inventory as inventory_module
+
+    resolver = AsyncMock(return_value=("GFL99", "GFSL99", "", ""))
+
+    manager = MagicMock()
+    manager.get_client = MagicMock(return_value=MagicMock())
+    manager.get_status = MagicMock(return_value=_State(diameter))
+    manager.get_model = MagicMock(return_value=model)
+
+    with (
+        patch.object(inventory_module, "resolve_slicer_filament", resolver),
+        patch("backend.app.services.printer_manager.printer_manager", manager),
+    ):
+        await inventory_module.apply_spool_to_slot_via_mqtt(
+            db=db_session,
+            current_user=None,
+            spool=spool,
+            printer_id=printer.id,
+            ams_id=0,
+            tray_id=0,
+        )
+
+    assert resolver.await_count == 1, "the assign path did not reach the preset resolver"
+    return resolver.await_args.kwargs
+
+
+class TestWithoutAnOverride:
+    async def test_the_spools_own_preset_is_used(self, db_session, printer_factory):
+        """Every spool in every existing install. Behaviour must be unchanged."""
+        printer = await printer_factory(model="H2C")
+        spool = await _spool(db_session)
+
+        kwargs = await _assign(db_session, spool, printer, model="H2C")
+
+        assert kwargs["slicer_filament"] == SPOOL_DEFAULT
+        assert kwargs["slicer_filament_name"] == SPOOL_DEFAULT_NAME
+
+
+class TestWithAModelOverride:
+    async def test_the_override_replaces_the_spools_preset(self, db_session, printer_factory):
+        printer = await printer_factory(model="H2C")
+        spool = await _spool(db_session)
+        db_session.add(
+            SpoolFilamentPreset(
+                spool_id=spool.id,
+                printer_model="H2C",
+                nozzle_diameter="",
+                slicer_filament="GFSA09",
+                slicer_filament_name="Bambu PLA Basic @BBL H2C",
+            )
+        )
+        await db_session.commit()
+
+        kwargs = await _assign(db_session, spool, printer, model="H2C")
+
+        assert kwargs["slicer_filament"] == "GFSA09"
+        assert kwargs["slicer_filament_name"] == "Bambu PLA Basic @BBL H2C"
+
+    async def test_a_different_model_still_gets_the_spools_preset(self, db_session, printer_factory):
+        """An override for the H2C must not follow the spool onto the X1C."""
+        printer = await printer_factory(model="X1C")
+        spool = await _spool(db_session)
+        db_session.add(
+            SpoolFilamentPreset(
+                spool_id=spool.id,
+                printer_model="H2C",
+                nozzle_diameter="",
+                slicer_filament="GFSA09",
+                slicer_filament_name="Bambu PLA Basic @BBL H2C",
+            )
+        )
+        await db_session.commit()
+
+        kwargs = await _assign(db_session, spool, printer, model="X1C")
+
+        assert kwargs["slicer_filament"] == SPOOL_DEFAULT
+
+
+class TestPerDiameterOverride:
+    async def test_the_slots_nozzle_diameter_selects_the_preset(self, db_session, printer_factory):
+        """The diameter comes from live printer state, so the same spool on
+        the same model resolves differently once a 0.2 nozzle is fitted."""
+        printer = await printer_factory(model="A1 mini")
+        spool = await _spool(db_session)
+        db_session.add_all(
+            [
+                SpoolFilamentPreset(
+                    spool_id=spool.id,
+                    printer_model="A1 mini",
+                    nozzle_diameter="",
+                    slicer_filament="GFSA20",
+                    slicer_filament_name="Bambu PLA Basic @BBL A1M",
+                ),
+                SpoolFilamentPreset(
+                    spool_id=spool.id,
+                    printer_model="A1 mini",
+                    nozzle_diameter="0.2",
+                    slicer_filament="GFSA21",
+                    slicer_filament_name="Bambu PLA Basic @BBL A1M 0.2 nozzle",
+                ),
+            ]
+        )
+        await db_session.commit()
+
+        on_04 = await _assign(db_session, spool, printer, model="A1 mini", diameter="0.4")
+        on_02 = await _assign(db_session, spool, printer, model="A1 mini", diameter="0.2")
+
+        assert on_04["slicer_filament"] == "GFSA20"
+        assert on_02["slicer_filament"] == "GFSA21"

+ 321 - 0
backend/tests/unit/test_rfid_assign_picks_the_right_hotend.py

@@ -0,0 +1,321 @@
+"""RFID auto-assign has to pick the K profile for the hotend the slot feeds.
+
+``auto_assign_spool`` runs unattended every time a Bambu spool is detected in a
+slot. It sends no ``ams_filament_setting`` -- the firmware already has the
+filament from the tag, and overwriting it turns the eye icon into a pen in
+Studio -- but it does select a K profile with ``extrusion_cali_sel``.
+
+It used to take the first stored row matching (printer, nozzle diameter) with
+no extruder test at all. On a dual-nozzle printer a spool calibrated on both
+hotends therefore had a coin toss decide which K value the slot got, and the
+losing side prints with the other nozzle's pressure advance.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import MagicMock
+
+import pytest
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+from sqlalchemy.orm import selectinload
+
+from backend.app.models.spool import Spool
+from backend.app.models.spool_k_profile import SpoolKProfile
+from backend.app.services.spool_tag_matcher import auto_assign_spool
+
+pytestmark = pytest.mark.asyncio
+
+RIGHT, LEFT = 0, 1
+
+
+class _Nozzle:
+    def __init__(self, diameter, nozzle_type=""):
+        self.nozzle_diameter = diameter
+        self.nozzle_type = nozzle_type
+
+
+class _State:
+    """Dual-nozzle printer, AMS 0 on the left hotend and AMS 1 on the right."""
+
+    def __init__(self, diameters=("0.4", "0.4"), types=("", "")):
+        self.nozzles = [_Nozzle(d, t) for d, t in zip(diameters, types, strict=False)]
+        self.ams_extruder_map = {"0": LEFT, "1": RIGHT}
+        self.ams_switch_inlet = None
+        self.raw_data = {}
+
+
+def _manager(state, client):
+    manager = MagicMock()
+    manager.get_status = MagicMock(return_value=state)
+    manager.get_client = MagicMock(return_value=client)
+    manager.get_model = MagicMock(return_value="H2D")
+    return manager
+
+
+async def _spool_with_both_hotends(engine, printer_id, diameters=("0.4", "0.4")):
+    maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
+    async with maker() as db:
+        spool = Spool(brand="Bambu", material="PLA", color_name="Black", slicer_filament="GFSA00")
+        db.add(spool)
+        await db.commit()
+        await db.refresh(spool)
+
+        # Same spool, calibrated on both hotends -- the maintainer's H2C reads
+        # 0.018 left and 0.020 right for one black PLA.
+        db.add_all(
+            [
+                SpoolKProfile(
+                    spool_id=spool.id,
+                    printer_id=printer_id,
+                    extruder=RIGHT,
+                    nozzle_diameter=diameters[RIGHT],
+                    k_value=0.020,
+                    cali_idx=15,
+                ),
+                SpoolKProfile(
+                    spool_id=spool.id,
+                    printer_id=printer_id,
+                    extruder=LEFT,
+                    nozzle_diameter=diameters[LEFT],
+                    k_value=0.018,
+                    cali_idx=16,
+                ),
+            ]
+        )
+        await db.commit()
+    return maker, spool.id
+
+
+async def _load(maker, spool_id) -> Spool:
+    async with maker() as db:
+        result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
+        return result.scalar_one()
+
+
+async def _assign(maker, spool, printer, ams_id, state):
+    client = MagicMock()
+    async with maker() as db:
+        await auto_assign_spool(printer.id, ams_id, 0, spool, _manager(state, client), db, tray_info_idx="GFA00")
+    return client
+
+
+class TestWhichHotendsProfileIsSelected:
+    async def test_a_slot_on_the_left_gets_the_left_profile(self, test_engine, printer_factory):
+        printer = await printer_factory(model="H2D")
+        maker, spool_id = await _spool_with_both_hotends(test_engine, printer.id)
+        spool = await _load(maker, spool_id)
+
+        client = await _assign(maker, spool, printer, ams_id=0, state=_State())
+
+        client.extrusion_cali_sel.assert_called_once()
+        assert client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 16
+
+    async def test_a_slot_on_the_right_gets_the_right_profile(self, test_engine, printer_factory):
+        printer = await printer_factory(model="H2D")
+        maker, spool_id = await _spool_with_both_hotends(test_engine, printer.id)
+        spool = await _load(maker, spool_id)
+
+        client = await _assign(maker, spool, printer, ams_id=1, state=_State())
+
+        assert client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 15
+
+    async def test_the_slots_own_nozzle_size_decides_the_diameter(self, test_engine, printer_factory):
+        """0.4 right, 0.2 left: the left slot must look up 0.2 profiles, which
+        is what reading nozzles[0] for every slot got wrong."""
+        printer = await printer_factory(model="H2D")
+        maker, spool_id = await _spool_with_both_hotends(test_engine, printer.id, diameters=("0.4", "0.2"))
+        spool = await _load(maker, spool_id)
+
+        client = await _assign(maker, spool, printer, ams_id=0, state=_State(("0.4", "0.2")))
+
+        kwargs = client.extrusion_cali_sel.call_args.kwargs
+        assert kwargs["nozzle_diameter"] == "0.2"
+        assert kwargs["cali_idx"] == 16
+
+
+class TestTheFilamentIdSentWithTheSelection:
+    """extrusion_cali_sel carries a filament id so the printer can link the
+    calibration index to the slot. A cloud USER preset id (PFUS/PFCN) is not one
+    the slicer accepts -- and a per-model override can now BE such an id, since
+    picking your own cloud preset for a model stores exactly that."""
+
+    async def test_a_cloud_user_preset_override_is_not_sent_to_the_printer(self, test_engine, printer_factory):
+        from backend.app.models.spool_filament_preset import SpoolFilamentPreset
+
+        printer = await printer_factory(model="H2D")
+        maker, spool_id = await _spool_with_both_hotends(test_engine, printer.id)
+        async with maker() as db:
+            db.add(
+                SpoolFilamentPreset(
+                    spool_id=spool_id,
+                    printer_model="H2D",
+                    nozzle_diameter="0.4",
+                    slicer_filament="PFUS279c9bd2c689d5",
+                    slicer_filament_name="# Bambu PETG HF @BBL H2D 0.4 nozzle",
+                )
+            )
+            await db.commit()
+        spool = await _load(maker, spool_id)
+
+        client = await _assign(maker, spool, printer, ams_id=0, state=_State())
+
+        # Falls through to the spool's own preset rather than sending a value
+        # the printer rejects, which would silently lose the K-profile link.
+        assert client.extrusion_cali_sel.call_args.kwargs["filament_id"] == "GFSA00"
+
+    async def test_a_normal_override_is_sent(self, test_engine, printer_factory):
+        from backend.app.models.spool_filament_preset import SpoolFilamentPreset
+
+        printer = await printer_factory(model="H2D")
+        maker, spool_id = await _spool_with_both_hotends(test_engine, printer.id)
+        async with maker() as db:
+            db.add(
+                SpoolFilamentPreset(
+                    spool_id=spool_id,
+                    printer_model="H2D",
+                    nozzle_diameter="0.4",
+                    slicer_filament="GFSG02_15",
+                    slicer_filament_name="Bambu PETG HF @BBL H2D",
+                )
+            )
+            await db.commit()
+        spool = await _load(maker, spool_id)
+
+        client = await _assign(maker, spool, printer, ams_id=0, state=_State())
+
+        assert client.extrusion_cali_sel.call_args.kwargs["filament_id"] == "GFSG02_15"
+
+
+class TestFallbacks:
+    async def test_a_profile_for_the_other_hotend_is_better_than_none(self, test_engine, printer_factory):
+        """An operator who calibrated one side only should still get that
+        profile rather than nothing -- the fallback the old code had by
+        accident, kept deliberately."""
+        printer = await printer_factory(model="H2D")
+        maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+        async with maker() as db:
+            spool = Spool(brand="Bambu", material="PLA", color_name="Black")
+            db.add(spool)
+            await db.commit()
+            await db.refresh(spool)
+            db.add(
+                SpoolKProfile(
+                    spool_id=spool.id,
+                    printer_id=printer.id,
+                    extruder=RIGHT,
+                    nozzle_diameter="0.4",
+                    k_value=0.020,
+                    cali_idx=15,
+                )
+            )
+            await db.commit()
+        loaded = await _load(maker, spool.id)
+
+        client = await _assign(maker, loaded, printer, ams_id=0, state=_State())
+
+        assert client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 15
+
+    async def test_a_profile_for_a_different_nozzle_size_is_not_used(self, test_engine, printer_factory):
+        """Diameter is not negotiable the way the hotend is: a K value measured
+        on a 0.6 says nothing about a 0.4."""
+        printer = await printer_factory(model="H2D")
+        maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+        async with maker() as db:
+            spool = Spool(brand="Bambu", material="PLA", color_name="Black")
+            db.add(spool)
+            await db.commit()
+            await db.refresh(spool)
+            db.add(
+                SpoolKProfile(
+                    spool_id=spool.id,
+                    printer_id=printer.id,
+                    extruder=LEFT,
+                    nozzle_diameter="0.6",
+                    k_value=0.030,
+                    cali_idx=20,
+                )
+            )
+            await db.commit()
+        loaded = await _load(maker, spool.id)
+
+        client = await _assign(maker, loaded, printer, ams_id=0, state=_State())
+
+        # No stored profile for 0.4 -- nothing is selected from the store.
+        selected = [c for c in client.extrusion_cali_sel.call_args_list if c.kwargs.get("cali_idx") == 20]
+        assert selected == []
+
+
+class TestFlowType:
+    """A K value measured through a high-flow nozzle is not a fact about a
+    standard one -- the printer files them as separate calibration entries, and
+    a machine can hold both for the same diameter."""
+
+    async def _spool_with_flows(self, engine, printer_id):
+        from backend.app.models.spool_k_profile import SpoolKProfile
+
+        maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
+        async with maker() as db:
+            spool = Spool(brand="Bambu", material="PLA", color_name="Black", slicer_filament="GFSA00")
+            db.add(spool)
+            await db.commit()
+            await db.refresh(spool)
+            db.add_all(
+                [
+                    SpoolKProfile(
+                        spool_id=spool.id,
+                        printer_id=printer_id,
+                        extruder=LEFT,
+                        nozzle_diameter="0.4",
+                        nozzle_type="HS",
+                        k_value=0.019,
+                        cali_idx=30,
+                    ),
+                    SpoolKProfile(
+                        spool_id=spool.id,
+                        printer_id=printer_id,
+                        extruder=LEFT,
+                        nozzle_diameter="0.4",
+                        nozzle_type="HH",
+                        k_value=0.026,
+                        cali_idx=31,
+                    ),
+                ]
+            )
+            await db.commit()
+        return maker, spool.id
+
+    async def test_the_fitted_flow_decides_which_profile_applies(self, test_engine, printer_factory):
+        printer = await printer_factory(model="H2D")
+        maker, spool_id = await self._spool_with_flows(test_engine, printer.id)
+        spool = await _load(maker, spool_id)
+
+        high = await _assign(maker, spool, printer, 0, _State(types=("HH01", "HH01")))
+        assert high.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 31
+
+        spool = await _load(maker, spool_id)
+        standard = await _assign(maker, spool, printer, 0, _State(types=("HS01", "HS01")))
+        assert standard.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 30
+
+    async def test_a_profile_with_no_stored_flow_still_applies(self, test_engine, printer_factory):
+        """Every profile saved before flow was recorded has none, so a strict
+        comparison would stop applying all of them at once."""
+        printer = await printer_factory(model="H2D")
+        maker, spool_id = await _spool_with_both_hotends(test_engine, printer.id)
+        spool = await _load(maker, spool_id)
+
+        client = await _assign(maker, spool, printer, 0, _State(types=("HH01", "HH01")))
+
+        assert client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 16
+
+    async def test_a_printer_that_declares_no_flow_applies_everything(self, test_engine, printer_factory):
+        """The X1C case, measured: it answers with nozzle_id '' on every
+        profile, so filtering on an invented Standard would drop the lot."""
+        printer = await printer_factory(model="H2D")
+        maker, spool_id = await self._spool_with_flows(test_engine, printer.id)
+        spool = await _load(maker, spool_id)
+
+        client = await _assign(maker, spool, printer, 0, _State(types=("", "")))
+
+        # Nothing is excluded, so the first stored row wins as it always did.
+        assert client.extrusion_cali_sel.call_args.kwargs["cali_idx"] in (30, 31)

+ 179 - 0
backend/tests/unit/test_slot_nozzle_resolution.py

@@ -0,0 +1,179 @@
+"""Which nozzle an AMS slot feeds.
+
+Every path that configures a slot needs the extruder and that nozzle's
+diameter, and both the filament preset and the K profile are stored per
+diameter -- so a wrong answer here silently selects the wrong preset AND the
+wrong K value. Seven call sites used to work it out independently, each reading
+``nozzles[0]`` for every slot on the machine.
+
+``nozzles[0]`` is right on a single-nozzle printer, and right on a dual-nozzle
+printer with matching nozzles, which is why it survived. These pin the case it
+is wrong for: two different sizes fitted, which is the machine the per-nozzle
+feature exists for.
+"""
+
+from __future__ import annotations
+
+from backend.app.services.slot_nozzle import (
+    DEFAULT_NOZZLE_DIAMETER,
+    SlotNozzle,
+    normalise_flow,
+    nozzle_diameter_for_extruder,
+    nozzle_flow_for_extruder,
+    resolve_slot_nozzle,
+)
+
+
+class _Nozzle:
+    def __init__(self, diameter: str):
+        self.nozzle_diameter = diameter
+        self.nozzle_type = ""
+
+
+class _State:
+    def __init__(self, diameters, ams_extruder_map=None, ams_switch_inlet=None, types=()):
+        self.nozzles = [_Nozzle(d) for d in diameters]
+        for nozzle, nozzle_type in zip(self.nozzles, types, strict=False):
+            nozzle.nozzle_type = nozzle_type
+        self.ams_extruder_map = ams_extruder_map
+        self.ams_switch_inlet = ams_switch_inlet
+
+
+# extruder 0 is the RIGHT hotend, 1 the left.
+MIXED = ["0.4", "0.2"]
+
+
+class TestDualNozzleMixedDiameters:
+    """The case the old shortcut got wrong."""
+
+    def test_each_hotend_reports_its_own_diameter(self):
+        state = _State(MIXED)
+        assert nozzle_diameter_for_extruder(state, 0, "H2C") == "0.4"
+        assert nozzle_diameter_for_extruder(state, 1, "H2C") == "0.2"
+
+    def test_the_slots_extruder_decides_which_one(self):
+        # AMS 0 is bound to the left hotend, AMS 1 to the right.
+        state = _State(MIXED, ams_extruder_map={"0": 1, "1": 0})
+
+        left = resolve_slot_nozzle(state, 0, 0, "H2C")
+        right = resolve_slot_nozzle(state, 1, 0, "H2C")
+        assert (left.extruder, left.diameter) == (1, "0.2")
+        assert (right.extruder, right.diameter) == (0, "0.4")
+
+    def test_external_slots_name_their_side_by_tray_id(self):
+        # Ext-L is tray 0 -> extruder 1, Ext-R is tray 1 -> extruder 0.
+        state = _State(MIXED)
+        assert resolve_slot_nozzle(state, 255, 0, "H2D").diameter == "0.2"
+        assert resolve_slot_nozzle(state, 255, 1, "H2D").diameter == "0.4"
+
+    def test_an_fts_inlet_answers_when_there_is_no_extruder_map(self):
+        state = _State(MIXED, ams_switch_inlet={"0": "A"})
+        resolved = resolve_slot_nozzle(state, 0, 0, "H2C")
+        assert resolved.extruder is not None
+        assert resolved.diameter in {"0.4", "0.2"}
+
+
+class TestSingleNozzle:
+    """Must behave exactly as the old shortcut did."""
+
+    def test_index_zero_whatever_the_map_says(self):
+        # A single-nozzle model has one entry; an extruder id from a stale map
+        # must not index past it.
+        state = _State(["0.6"], ams_extruder_map={"0": 1})
+        assert nozzle_diameter_for_extruder(state, 1, "X1C") == "0.6"
+        assert resolve_slot_nozzle(state, 0, 0, "X1C").diameter == "0.6"
+
+    def test_no_map_means_unknown_extruder_not_zero(self):
+        # "I don't know" and "the right-hand nozzle" are different answers; the
+        # caller's own default of 0 is correct on a single-nozzle machine, but
+        # storing it as a fact is what bound a left K-profile to a right slot.
+        state = _State(["0.4"])
+        resolved = resolve_slot_nozzle(state, 0, 0, "X1C")
+        assert resolved.extruder is None
+        assert resolved.extruder_or_default == 0
+
+
+class TestMissingHardware:
+    """Called on every assign, so it must never raise."""
+
+    def test_no_state_at_all(self):
+        resolved = resolve_slot_nozzle(None, 0, 0, "H2C")
+        assert (resolved.extruder, resolved.diameter) == (None, DEFAULT_NOZZLE_DIAMETER)
+
+    def test_printer_has_reported_no_nozzles(self):
+        assert nozzle_diameter_for_extruder(_State([]), 0, "H2C") == DEFAULT_NOZZLE_DIAMETER
+
+    def test_the_second_hotend_is_absent_from_the_report(self):
+        # H2C parks a nozzle back in its rack and the entry can go missing;
+        # falling back to the other hotend beats returning nothing.
+        assert nozzle_diameter_for_extruder(_State(["0.4"]), 1, "H2C") == "0.4"
+
+    def test_a_blank_diameter_is_not_a_diameter(self):
+        # NozzleInfo starts life with an empty string before MQTT fills it in.
+        # Falling back to the OTHER hotend's size would invent a fact about a
+        # different nozzle, so a blank primary takes the default instead --
+        # which is what every call site did before this module existed.
+        assert nozzle_diameter_for_extruder(_State(["", "0.6"]), 0, "H2C") == DEFAULT_NOZZLE_DIAMETER
+        assert nozzle_diameter_for_extruder(_State(["", ""]), 0, "H2C") == DEFAULT_NOZZLE_DIAMETER
+
+
+class TestMatchingNozzles:
+    """The common fleet: both hotends the same size."""
+
+    def test_both_conventions_agree_so_the_answer_cannot_be_wrong(self):
+        state = _State(["0.4", "0.4"], ams_extruder_map={"0": 1})
+        assert nozzle_diameter_for_extruder(state, 0, "H2C") == "0.4"
+        assert nozzle_diameter_for_extruder(state, 1, "H2C") == "0.4"
+
+
+class TestFlowType:
+    """High Flow vs Standard. The same filament reads a different K through
+    each, and a printer files them as separate calibration entries."""
+
+    def test_the_fitted_nozzles_flow_is_read_per_hotend(self):
+        # Measured spelling: a fitted nozzle reports HH01/HS01, while a
+        # calibration entry says HH00-0.4 -- two characters is the comparison.
+        state = _State(MIXED, types=("HH01", "HS01"))
+        assert nozzle_flow_for_extruder(state, 0, "H2C") == "HH"
+        assert nozzle_flow_for_extruder(state, 1, "H2C") == "HS"
+
+    def test_a_printer_that_declares_no_flow_answers_none(self):
+        # An X1C sends no flow on any profile, and legacy printers put the
+        # nozzle MATERIAL in this field. Neither is a flow type.
+        assert nozzle_flow_for_extruder(_State(["0.4"], types=("",)), 0, "X1C") is None
+        assert nozzle_flow_for_extruder(_State(["0.4"], types=("hardened_steel",)), 0, "X1C") is None
+
+    def test_normalise_accepts_both_spellings(self):
+        assert normalise_flow("HH00-0.4") == "HH"
+        assert normalise_flow("HH01") == "HH"
+        assert normalise_flow("hs00-0.4") == "HS"
+        assert normalise_flow("") is None
+        assert normalise_flow(None) is None
+
+
+class TestFlowMatching:
+    """Which stored profiles apply to the nozzle now fitted."""
+
+    def test_the_flows_must_agree_once_both_are_known(self):
+        high = SlotNozzle(extruder=0, diameter="0.4", flow="HH")
+        assert high.flow_matches("HH00") is True
+        assert high.flow_matches("HS00") is False
+
+    def test_a_profile_with_no_stored_flow_still_applies(self):
+        # Every profile saved before flow was recorded has NULL here -- a
+        # strict comparison would stop applying all of them at once.
+        high = SlotNozzle(extruder=0, diameter="0.4", flow="HH")
+        assert high.flow_matches(None) is True
+        assert high.flow_matches("") is True
+
+    def test_a_printer_with_no_fitted_flow_applies_everything(self):
+        # The X1C case: filtering on an invented Standard would drop every
+        # profile the moment a high-flow nozzle was fitted.
+        unknown = SlotNozzle(extruder=0, diameter="0.4", flow=None)
+        assert unknown.flow_matches("HH00") is True
+        assert unknown.flow_matches("HS00") is True
+
+    def test_resolve_carries_the_flow_with_the_diameter(self):
+        state = _State(MIXED, ams_extruder_map={"0": 1, "1": 0}, types=("HH01", "HS01"))
+        left = resolve_slot_nozzle(state, 0, 0, "H2C")
+        assert (left.extruder, left.diameter, left.flow) == (1, "0.2", "HS")

+ 206 - 0
backend/tests/unit/test_spool_filament_preset_cascade.py

@@ -0,0 +1,206 @@
+"""The per-printer-model preset cascade.
+
+``Spool.slicer_filament`` holds one preset and is printer-agnostic by design.
+That breaks as soon as a spool is used on two printer models, because a cloud
+or Orca preset is bound to a model (``@BBL X1C``): the AMS slot on an H2C gets
+configured with a preset that machine has no profile for.
+
+``services.spool_filament_preset`` resolves, most specific first:
+
+    (model, diameter) -> (model, "") -> the spool's own slicer_filament
+
+These pin the resolution order itself, including the two cases that are easy
+to get backwards: a spool with no overrides must behave exactly as it did
+before the feature existed, and a stored row whose preset is blank is the
+user clearing the preset for that model, not a hole to fall through.
+"""
+
+from __future__ import annotations
+
+import pytest
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from backend.app.models.spool import Spool
+from backend.app.models.spool_filament_preset import SpoolFilamentPreset, SpoolmanFilamentPreset
+from backend.app.services.spool_filament_preset import resolve_spool_preset, resolve_spoolman_preset
+
+pytestmark = pytest.mark.asyncio
+
+DEFAULT = ("GFSA00", "Bambu PLA Basic @BBL X1C")
+
+
+async def _spool(engine) -> tuple[async_sessionmaker, int]:
+    maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
+    async with maker() as db:
+        spool = Spool(
+            brand="Bambu",
+            material="PLA",
+            color_name="Charcoal",
+            slicer_filament=DEFAULT[0],
+            slicer_filament_name=DEFAULT[1],
+        )
+        db.add(spool)
+        await db.commit()
+        await db.refresh(spool)
+        return maker, spool.id
+
+
+async def _resolve(maker, spool_id, model, diameter):
+    async with maker() as db:
+        return await resolve_spool_preset(
+            db,
+            spool_id=spool_id,
+            printer_model=model,
+            nozzle_diameter=diameter,
+            fallback_filament=DEFAULT[0],
+            fallback_name=DEFAULT[1],
+        )
+
+
+async def _add(maker, spool_id, model, diameter, code, name):
+    async with maker() as db:
+        db.add(
+            SpoolFilamentPreset(
+                spool_id=spool_id,
+                printer_model=model,
+                nozzle_diameter=diameter,
+                slicer_filament=code,
+                slicer_filament_name=name,
+            )
+        )
+        await db.commit()
+
+
+class TestNoOverrides:
+    """Every spool in every existing install is this case."""
+
+    async def test_falls_back_to_the_spools_own_preset(self, test_engine):
+        maker, spool_id = await _spool(test_engine)
+        assert await _resolve(maker, spool_id, "H2C", "0.4") == DEFAULT
+
+    async def test_an_unknown_model_falls_back(self, test_engine):
+        maker, spool_id = await _spool(test_engine)
+        await _add(maker, spool_id, "X1C", "", "GFSA01", "PLA @X1C")
+        assert await _resolve(maker, spool_id, "P1S", "0.4") == DEFAULT
+
+    async def test_no_model_at_all_falls_back(self, test_engine):
+        """A printer that has not reported its model yet cannot be more
+        specific than the spool itself -- it must not match some other row."""
+        maker, spool_id = await _spool(test_engine)
+        await _add(maker, spool_id, "X1C", "", "GFSA01", "PLA @X1C")
+        assert await _resolve(maker, spool_id, None, "0.4") == DEFAULT
+        assert await _resolve(maker, spool_id, "", "0.4") == DEFAULT
+
+
+class TestModelDefault:
+    """Diameter "" = any nozzle of the model.
+
+    The spool form writes one row per nozzle size and never this one, but the
+    API accepts it and it has to keep resolving -- these pin that level of the
+    cascade so a client using it does not break silently.
+    """
+
+    async def test_model_row_wins_over_the_spool_default(self, test_engine):
+        maker, spool_id = await _spool(test_engine)
+        await _add(maker, spool_id, "H2C", "", "GFSA09", "Bambu PLA Basic @BBL H2C")
+        assert await _resolve(maker, spool_id, "H2C", "0.4") == ("GFSA09", "Bambu PLA Basic @BBL H2C")
+
+    async def test_it_applies_to_every_nozzle_of_that_model(self, test_engine):
+        maker, spool_id = await _spool(test_engine)
+        await _add(maker, spool_id, "H2C", "", "GFSA09", "Bambu PLA Basic @BBL H2C")
+        for diameter in ("0.2", "0.4", "0.6", "0.8", ""):
+            assert (await _resolve(maker, spool_id, "H2C", diameter))[0] == "GFSA09", diameter
+
+    async def test_other_models_are_untouched(self, test_engine):
+        maker, spool_id = await _spool(test_engine)
+        await _add(maker, spool_id, "H2C", "", "GFSA09", "Bambu PLA Basic @BBL H2C")
+        await _add(maker, spool_id, "A1 mini", "", "GFSA20", "Bambu PLA Basic @BBL A1M")
+        assert (await _resolve(maker, spool_id, "H2C", "0.4"))[0] == "GFSA09"
+        assert (await _resolve(maker, spool_id, "A1 mini", "0.4"))[0] == "GFSA20"
+        assert (await _resolve(maker, spool_id, "X1C", "0.4"))[0] == DEFAULT[0]
+
+
+class TestPerDiameterException:
+    """Why diameter is in the key at all: the preset lands on an AMS slot and
+    a slot feeds exactly one nozzle, so a machine with two diameters fitted
+    needs two answers for one model."""
+
+    async def test_exact_diameter_beats_the_model_default(self, test_engine):
+        maker, spool_id = await _spool(test_engine)
+        await _add(maker, spool_id, "A1 mini", "", "GFSA20", "Bambu PLA Basic @BBL A1M")
+        await _add(maker, spool_id, "A1 mini", "0.2", "GFSA21", "Bambu PLA Basic @BBL A1M 0.2 nozzle")
+
+        assert (await _resolve(maker, spool_id, "A1 mini", "0.2"))[0] == "GFSA21"
+        assert (await _resolve(maker, spool_id, "A1 mini", "0.4"))[0] == "GFSA20"
+
+    async def test_a_diameter_row_alone_still_leaves_other_nozzles_on_the_default(self, test_engine):
+        maker, spool_id = await _spool(test_engine)
+        await _add(maker, spool_id, "A1 mini", "0.2", "GFSA21", "Bambu PLA Basic @BBL A1M 0.2 nozzle")
+
+        assert (await _resolve(maker, spool_id, "A1 mini", "0.2"))[0] == "GFSA21"
+        assert (await _resolve(maker, spool_id, "A1 mini", "0.4"))[0] == DEFAULT[0]
+
+    async def test_the_two_hotends_of_one_machine_resolve_differently(self, test_engine):
+        """The case that put diameter in the key: 0.4 on one hotend, 0.2 on
+        the other, one model, one spool."""
+        maker, spool_id = await _spool(test_engine)
+        await _add(maker, spool_id, "H2C", "0.4", "GFSA09", "Bambu PLA Basic @BBL H2C")
+        await _add(maker, spool_id, "H2C", "0.2", "GFSA10", "Bambu PLA Basic @BBL H2C 0.2 nozzle")
+
+        assert (await _resolve(maker, spool_id, "H2C", "0.4"))[0] == "GFSA09"
+        assert (await _resolve(maker, spool_id, "H2C", "0.2"))[0] == "GFSA10"
+
+
+class TestClearedPreset:
+    async def test_a_blank_row_means_none_not_fall_through(self, test_engine):
+        """The user picking the empty entry for a model is a decision. Falling
+        back to the spool's preset would silently reinstate what they cleared,
+        and it is the spool's preset that is wrong on that model."""
+        maker, spool_id = await _spool(test_engine)
+        await _add(maker, spool_id, "H2C", "", None, None)
+        assert await _resolve(maker, spool_id, "H2C", "0.4") == (None, None)
+
+
+class TestSpoolmanFlavour:
+    """Spoolman spools live in Spoolman; the override is Bambuddy's, keyed by
+    the remote id. Same cascade -- the two inventory modes must not drift."""
+
+    async def test_same_cascade(self, test_engine):
+        maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+        async with maker() as db:
+            db.add(
+                SpoolmanFilamentPreset(
+                    spoolman_spool_id=7,
+                    printer_model="H2C",
+                    nozzle_diameter="",
+                    slicer_filament="GFSA09",
+                    slicer_filament_name="Bambu PLA Basic @BBL H2C",
+                )
+            )
+            db.add(
+                SpoolmanFilamentPreset(
+                    spoolman_spool_id=7,
+                    printer_model="H2C",
+                    nozzle_diameter="0.2",
+                    slicer_filament="GFSA10",
+                    slicer_filament_name="Bambu PLA Basic @BBL H2C 0.2 nozzle",
+                )
+            )
+            await db.commit()
+
+        async def resolve(model, diameter, spool_id=7):
+            async with maker() as db:
+                return await resolve_spoolman_preset(
+                    db,
+                    spoolman_spool_id=spool_id,
+                    printer_model=model,
+                    nozzle_diameter=diameter,
+                    fallback_filament=DEFAULT[0],
+                    fallback_name=DEFAULT[1],
+                )
+
+        assert (await resolve("H2C", "0.4"))[0] == "GFSA09"
+        assert (await resolve("H2C", "0.2"))[0] == "GFSA10"
+        assert (await resolve("X1C", "0.4"))[0] == DEFAULT[0]
+        # Another spool's rows must not leak into this one.
+        assert (await resolve("H2C", "0.4", spool_id=8))[0] == DEFAULT[0]

+ 1 - 0
frontend/scripts/check-i18n-parity.mjs

@@ -421,6 +421,7 @@ const UK_COGNATES = [
 // are used untranslated by Dutch slicer users. Each entry below was
 // checked individually against the Dutch translation in #2891.
 const NL_COGNATES = [
+  '1 printer', '{{n}} printers',
   '1 week', '(25%, 50%, 75%)', 'Accent', 'AMS Filament Backup',
   '{{ams}} Slot {{slot}}', 'Auto', 'Auto Home', 'Bambu Cloud',
   'Batch', 'Batches', 'Branch', 'Budget',

+ 701 - 0
frontend/src/__tests__/components/PrinterProfilesSection.test.tsx

@@ -0,0 +1,701 @@
+/**
+ * The spool form's Printers tab.
+ *
+ * Two things are keyed differently and the UI has to keep them apart: the
+ * filament preset belongs to the printer MODEL (an "@BBL X1C" preset is the
+ * same preset on every X1C), while a K profile is measured on one individual
+ * hotend and stays per printer, per extruder, per nozzle diameter.
+ *
+ * The layout is a model list plus a detail pane so that fleet size stops
+ * mattering -- these drive it with a deliberately mixed fleet: two machines of
+ * one model, a dual-nozzle machine with two different diameters fitted, and an
+ * offline printer.
+ */
+
+import React from 'react';
+import { describe, it, expect, vi } from 'vitest';
+import { screen, fireEvent, within } from '@testing-library/react';
+import { render } from '../utils';
+import { PrinterProfilesSection } from '../../components/spool-form/PrinterProfilesSection';
+import { presetKey, hotendKey } from '../../components/spool-form/utils';
+import { defaultFormData } from '../../components/spool-form/types';
+import type {
+  CalibrationProfile,
+  FilamentOption,
+  PresetChoice,
+  PrinterWithCalibrations,
+} from '../../components/spool-form/types';
+
+const OPTIONS: FilamentOption[] = [
+  { code: 'GFSA00', name: 'Bambu PLA Basic @BBL X1C', displayName: 'Bambu PLA Basic @BBL X1C', isCustom: false, allCodes: ['GFSA00'], source: 'cloud' },
+  { code: 'GFSA09', name: 'Bambu PLA Basic @BBL H2C', displayName: 'Bambu PLA Basic @BBL H2C', isCustom: false, allCodes: ['GFSA09'], source: 'cloud' },
+  { code: 'GFSA21', name: 'Bambu PLA Basic @BBL H2C 0.2 nozzle', displayName: 'Bambu PLA Basic @BBL H2C 0.2 nozzle', isCustom: false, allCodes: ['GFSA21'], source: 'orca_cloud' },
+  // Names no model at all -- the shape most user-authored and Orca presets
+  // have. Must stay offered everywhere: hiding what cannot be classified
+  // would hide most third-party profiles.
+  { code: 'LOCAL1', name: 'eSUN PETG Basic', displayName: 'eSUN PETG Basic (Local)', isCustom: true, allCodes: ['LOCAL1'], source: 'local' },
+];
+
+/** The backend registry shape: "Bambu Lab <long>" -> short code. */
+const PRINTER_MODELS: Record<string, string> = {
+  'Bambu Lab X1 Carbon': 'X1C',
+  'Bambu Lab H2C': 'H2C',
+  'Bambu Lab P1S': 'P1S',
+  'Bambu Lab H2D': 'H2D',
+};
+
+function cal(overrides: Partial<CalibrationProfile>): CalibrationProfile {
+  return {
+    cali_idx: 1,
+    filament_id: 'GFL99',
+    setting_id: 'GFSL99',
+    name: 'PLA Basic',
+    k_value: 0.02,
+    n_coef: 1.0,
+    extruder_id: 0,
+    nozzle_diameter: '0.4',
+    nozzle_id: '',
+    ...overrides,
+  };
+}
+
+function printer(
+  id: number,
+  name: string,
+  model: string | null,
+  opts: {
+    connected?: boolean;
+    nozzleCount?: number;
+    calibrations?: CalibrationProfile[];
+    nozzles?: { nozzle_diameter?: string; nozzle_type?: string }[];
+  } = {},
+): PrinterWithCalibrations {
+  return {
+    printer: {
+      id,
+      name,
+      model,
+      connected: opts.connected ?? true,
+      nozzle_count: opts.nozzleCount ?? 1,
+    } as PrinterWithCalibrations['printer'],
+    calibrations: opts.calibrations ?? [cal({})],
+    nozzles: opts.nozzles,
+  };
+}
+
+/** Two X1Cs, one dual-nozzle H2C with 0.4 + 0.2 fitted, one offline P1S. */
+function fleet(): PrinterWithCalibrations[] {
+  return [
+    printer(1, 'X1C-1', 'X1C'),
+    printer(2, 'X1C-2', 'X1C', { calibrations: [cal({ cali_idx: 2, k_value: 0.019 })] }),
+    printer(3, 'H2C-1', 'H2C', {
+      nozzleCount: 2,
+      nozzles: [{ nozzle_diameter: '0.4' }, { nozzle_diameter: '0.2' }],
+      calibrations: [
+        cal({ cali_idx: 3, extruder_id: 0, nozzle_diameter: '0.4', k_value: 0.021 }),
+        cal({ cali_idx: 16, extruder_id: 1, nozzle_diameter: '0.2', k_value: 0.014, name: 'PLA 0.2' }),
+      ],
+    }),
+    printer(4, 'P1S-1', 'P1S', { connected: false }),
+  ];
+}
+
+interface HarnessProps {
+  printers?: PrinterWithCalibrations[];
+  presets?: Map<string, PresetChoice>;
+  profiles?: Map<string, CalibrationProfile>;
+  onPresets?: (next: Map<string, PresetChoice>) => void;
+  onProfiles?: (next: Map<string, CalibrationProfile>) => void;
+  slicerFilament?: string;
+}
+
+/**
+ * Renders the section with real state, so a click is asserted through the
+ * component's own update path rather than against a spy on the setter.
+ */
+function Harness({
+  printers = fleet(),
+  presets = new Map(),
+  profiles = new Map(),
+  onPresets,
+  onProfiles,
+  slicerFilament = 'GFSA00',
+}: HarnessProps) {
+  const [modelPresets, setModelPresets] = React.useState(presets);
+  const [selectedProfiles, setSelectedProfiles] = React.useState(profiles);
+  const [selectedGroupId, setSelectedGroupId] = React.useState('');
+
+  React.useEffect(() => {
+    onPresets?.(modelPresets);
+  }, [modelPresets, onPresets]);
+  React.useEffect(() => {
+    onProfiles?.(selectedProfiles);
+  }, [selectedProfiles, onProfiles]);
+
+  return (
+    <PrinterProfilesSection
+      formData={{ ...defaultFormData, material: 'PLA', brand: 'Bambu', slicer_filament: slicerFilament }}
+      printersWithCalibrations={printers}
+      filamentOptions={OPTIONS}
+      modelPresets={modelPresets}
+      setModelPresets={setModelPresets}
+      selectedProfiles={selectedProfiles}
+      setSelectedProfiles={setSelectedProfiles}
+      selectedGroupId={selectedGroupId}
+      setSelectedGroupId={setSelectedGroupId}
+      printerModels={PRINTER_MODELS}
+    />
+  );
+}
+
+/**
+ * The preset pickers in the detail pane, in render order.
+ *
+ * Each is a button labelled "<model> <size>mm Filament preset" -- not a native
+ * <select>, because every option carries an origin badge (Bambu Cloud / Orca
+ * Cloud / Local / Built-in) and a <select> cannot render one.
+ */
+function presetPickers(): HTMLElement[] {
+  return screen.getAllByRole('button', { name: /Filament preset$/ });
+}
+
+function presetPicker(model: string, diameter: string): HTMLElement {
+  return screen.getByRole('button', { name: `${model} ${diameter}mm Filament preset` });
+}
+
+/** Open one picker and return the options it is offering. */
+function openPicker(picker: HTMLElement): HTMLElement[] {
+  fireEvent.click(picker);
+  return screen.getAllByRole('option');
+}
+
+function optionNamed(pattern: RegExp | string): HTMLElement {
+  return screen.getByRole('option', { name: pattern });
+}
+
+/**
+ * A model's row in the left rail. By role: the model name also appears as the
+ * detail pane's heading, and the pane is full of other buttons.
+ */
+function modelRow(model: string): HTMLElement {
+  return screen.getByRole('tab', { name: new RegExp(`^\\s*${model}\\b`) });
+}
+
+/** The detail pane's heading, i.e. which model is currently open. */
+function openModel(): string {
+  return screen.getByRole('heading').textContent ?? '';
+}
+
+describe('PrinterProfilesSection — spool identity', () => {
+  it('names the spool being configured, colour included', () => {
+    // This tab is the one place you read printer names rather than filament,
+    // and the K lists below are filtered by exactly these fields -- so the line
+    // also explains an empty list.
+    const Named = () => {
+      const [presets, setPresets] = React.useState(new Map<string, PresetChoice>());
+      const [profiles, setProfiles] = React.useState(new Map<string, CalibrationProfile>());
+      const [group, setGroup] = React.useState('');
+      return (
+        <PrinterProfilesSection
+          formData={{
+            ...defaultFormData,
+            brand: 'Bambu',
+            material: 'PLA',
+            subtype: 'Matte',
+            color_name: 'Scarlet Red',
+            rgba: 'DE4343FF',
+          }}
+          printersWithCalibrations={fleet()}
+          filamentOptions={OPTIONS}
+          modelPresets={presets}
+          setModelPresets={setPresets}
+          selectedProfiles={profiles}
+          setSelectedProfiles={setProfiles}
+          selectedGroupId={group}
+          setSelectedGroupId={setGroup}
+        />
+      );
+    };
+    render(<Named />);
+    expect(screen.getByText('Bambu PLA Matte')).toBeInTheDocument();
+    expect(screen.getByText('Scarlet Red')).toBeInTheDocument();
+  });
+
+  it('renders no identity bar at all when the spool has nothing to name yet', () => {
+    const Blank = () => {
+      const [presets, setPresets] = React.useState(new Map<string, PresetChoice>());
+      const [profiles, setProfiles] = React.useState(new Map<string, CalibrationProfile>());
+      const [group, setGroup] = React.useState('');
+      return (
+        <PrinterProfilesSection
+          formData={{ ...defaultFormData, brand: '', material: '', subtype: '', color_name: '' }}
+          printersWithCalibrations={fleet()}
+          filamentOptions={OPTIONS}
+          modelPresets={presets}
+          setModelPresets={setPresets}
+          selectedProfiles={profiles}
+          setSelectedProfiles={setProfiles}
+          selectedGroupId={group}
+          setSelectedGroupId={setGroup}
+        />
+      );
+    };
+    render(<Blank />);
+    // An empty bar, or one repeating the K section's "select a material first",
+    // would be noise -- the K section already says it once.
+    expect(screen.getAllByText(/select a material first/i)).toHaveLength(1);
+  });
+});
+
+describe('PrinterProfilesSection — model list', () => {
+  it('lists each model once, however many machines it has', () => {
+    render(<Harness />);
+    expect(modelRow('X1C')).toBeInTheDocument();
+    expect(modelRow('H2C')).toBeInTheDocument();
+    expect(modelRow('P1S')).toBeInTheDocument();
+    // Two X1Cs, one row -- the rail is per model, not per machine.
+    expect(within(modelRow('X1C')).getByText('2 printers')).toBeInTheDocument();
+  });
+
+  it('shows the first model by default and switches on click', () => {
+    render(<Harness />);
+    // Alphabetical: H2C first. Its machine is named in the detail pane.
+    expect(openModel()).toBe('H2C');
+    // Named twice on purpose: once in the pane subtitle, once on its own card.
+    expect(screen.getAllByText('H2C-1').length).toBeGreaterThan(0);
+
+    fireEvent.click(modelRow('X1C'));
+    expect(openModel()).toBe('X1C');
+    expect(screen.getByText('X1C-1, X1C-2')).toBeInTheDocument();
+  });
+
+  it('counts hotends with no K profile chosen', () => {
+    render(<Harness />);
+    // H2C has two hotends and nothing chosen yet.
+    expect(within(modelRow('H2C')).getByText('2')).toBeInTheDocument();
+  });
+});
+
+describe('PrinterProfilesSection — filament preset', () => {
+  it('starts inherited and stores an override when one is picked', () => {
+    let latest: Map<string, PresetChoice> = new Map();
+    render(<Harness onPresets={next => { latest = next; }} />);
+
+    // One badge per preset row -- one row per nozzle size.
+    expect(screen.getAllByText('inherited')).toHaveLength(presetPickers().length);
+
+    fireEvent.click(presetPicker('H2C', '0.4'));
+    fireEvent.click(optionNamed('Bambu PLA Basic @BBL H2C Bambu Cloud'));
+
+    expect(latest.get(presetKey('H2C', '0.4'))).toEqual({
+      code: 'GFSA09',
+      name: 'Bambu PLA Basic @BBL H2C',
+    });
+    expect(screen.getByText('override')).toBeInTheDocument();
+  });
+
+  it('clearing an override removes the row rather than storing the spool value', () => {
+    // A row repeating the spool's own preset would freeze it: later edits to
+    // the spool preset would stop reaching this model. Absent means inherit.
+    let latest: Map<string, PresetChoice> = new Map();
+    render(
+      <Harness
+        presets={new Map([[presetKey('H2C', '0.4'), { code: 'GFSA09', name: 'Bambu PLA Basic @BBL H2C' }]])}
+        onPresets={next => { latest = next; }}
+      />,
+    );
+
+    fireEvent.click(presetPicker('H2C', '0.4'));
+    fireEvent.click(optionNamed(/use the spool's preset/i));
+
+    expect(latest.has(presetKey('H2C', '0.4'))).toBe(false);
+  });
+
+  it('offers one preset row per nozzle size and nothing above them', () => {
+    render(<Harness />);
+    // Every standard size, not only the ones fitted: a spool is configured
+    // once and nozzles get swapped. No model-wide row -- the preset lands on
+    // an AMS slot and a slot feeds exactly one nozzle.
+    expect(presetPickers()).toHaveLength(4);
+    // (Each size also labels a K-profile grid row, hence getAll.)
+    expect(screen.getAllByText('0.4mm').length).toBeGreaterThan(0);
+    expect(screen.getAllByText('0.2mm').length).toBeGreaterThan(0);
+
+    // Same for a model with a single diameter fitted across both machines.
+    fireEvent.click(modelRow('X1C'));
+    expect(presetPickers()).toHaveLength(4);
+  });
+
+  it('a pick is stored under the size it was made on', () => {
+    let latest: Map<string, PresetChoice> = new Map();
+    render(<Harness onPresets={next => { latest = next; }} />);
+
+    fireEvent.click(presetPicker('H2C', '0.2'));
+    fireEvent.click(optionNamed(/0\.2 nozzle/));
+
+    expect(latest.get(presetKey('H2C', '0.2'))?.code).toBe('GFSA21');
+    // Only that size -- the other rows are untouched.
+    expect(latest.size).toBe(1);
+  });
+
+  it('refuses to offer a preset for a printer whose model is unknown', () => {
+    render(<Harness printers={[printer(9, 'Mystery', null)]} />);
+    expect(screen.getByText(/has not reported its model/i)).toBeInTheDocument();
+  });
+});
+
+describe('PrinterProfilesSection — presets offered per model', () => {
+  function optionNames(picker: HTMLElement): string[] {
+    return openPicker(picker).map(o => o.textContent ?? '');
+  }
+
+  it('offers a model only the presets that name it', () => {
+    render(<Harness />);
+    const names = optionNames(presetPicker('H2C', '0.4'));
+
+    expect(names.some(n => n.includes('Bambu PLA Basic @BBL H2C'))).toBe(true);
+    expect(names.some(n => n.includes('Bambu PLA Basic @BBL X1C'))).toBe(false);
+  });
+
+  it('keeps presets whose model cannot be read from the name', () => {
+    render(<Harness />);
+    const names = optionNames(presetPicker('H2C', '0.4'));
+    expect(names.some(n => n.includes('eSUN PETG Basic (Local)'))).toBe(true);
+  });
+
+  it('switching model switches which presets are offered', () => {
+    render(<Harness />);
+    fireEvent.click(modelRow('X1C'));
+    const names = optionNames(presetPicker('X1C', '0.4'));
+    expect(names.some(n => n.includes('Bambu PLA Basic @BBL X1C'))).toBe(true);
+    expect(names.some(n => n.includes('Bambu PLA Basic @BBL H2C'))).toBe(false);
+  });
+
+  it('never hides an override that is already saved', () => {
+    // The stored value may name another model -- picked before the filter
+    // existed, or by hand. Dropping it from the list would blank the control
+    // and quietly change what gets saved.
+    render(
+      <Harness
+        presets={new Map([[presetKey('X1C', '0.4'), { code: 'GFSA09', name: 'Bambu PLA Basic @BBL H2C' }]])}
+      />,
+    );
+    fireEvent.click(modelRow('X1C'));
+    const picker = presetPicker('X1C', '0.4');
+    expect(picker.textContent).toContain('Bambu PLA Basic @BBL H2C');
+    expect(optionNames(picker).some(n => n.includes('Bambu PLA Basic @BBL H2C'))).toBe(true);
+  });
+
+  it('badges each preset with the source it came from', () => {
+    // The same filament exists as a cloud preset, an imported one and a
+    // built-in, and which one is picked decides what reaches the printer --
+    // so the origin is shown here exactly as the Configure AMS Slot modal
+    // shows it.
+    render(<Harness />);
+    const options = openPicker(presetPicker('H2C', '0.4'));
+
+    const cloud = options.find(o => o.textContent?.includes('@BBL H2C'));
+    expect(cloud?.textContent).toContain('Bambu Cloud');
+    const local = options.find(o => o.textContent?.includes('eSUN PETG Basic'));
+    expect(local?.textContent).toContain('Local');
+  });
+
+  it('filters the list as you type', () => {
+    render(<Harness />);
+    fireEvent.click(presetPicker('H2C', '0.4'));
+    fireEvent.change(screen.getByPlaceholderText(/search filament presets/i), {
+      target: { value: 'esun' },
+    });
+
+    const names = screen.getAllByRole('option').map(o => o.textContent ?? '');
+    expect(names.some(n => n.includes('eSUN'))).toBe(true);
+    expect(names.some(n => n.includes('Bambu PLA Basic'))).toBe(false);
+  });
+});
+
+describe('PrinterProfilesSection — every nozzle size', () => {
+  it('offers a preset row for all four standard sizes, fitted or not', () => {
+    // A spool is configured once and nozzles get swapped. X1C has only a 0.4
+    // fitted, and must still offer 0.2 / 0.6 / 0.8.
+    render(<Harness />);
+    fireEvent.click(modelRow('X1C'));
+
+    expect(presetPickers()).toHaveLength(4);
+    for (const size of ['0.2mm', '0.4mm', '0.6mm', '0.8mm']) {
+      expect(screen.getAllByText(size).length).toBeGreaterThan(0);
+    }
+  });
+
+  it('stores a preset for a size that is not currently fitted', () => {
+    let latest: Map<string, PresetChoice> = new Map();
+    render(<Harness onPresets={next => { latest = next; }} />);
+    fireEvent.click(modelRow('X1C'));
+
+    fireEvent.click(presetPicker('X1C', '0.6'));
+    fireEvent.click(optionNamed('Bambu PLA Basic @BBL X1C Bambu Cloud'));
+
+    expect(latest.get(presetKey('X1C', '0.6'))?.code).toBe('GFSA00');
+  });
+
+  it('offers a K profile for a size the printer has profiles for but has not fitted', () => {
+    // fetchPrinterCalibrations now asks for every standard size, so a 0.6
+    // profile reaches the picker without a 0.6 being screwed in.
+    let latest: Map<string, CalibrationProfile> = new Map();
+    const withSpare = [
+      printer(1, 'X1C-1', 'X1C', {
+        nozzles: [{ nozzle_diameter: '0.4' }],
+        calibrations: [
+          cal({ cali_idx: 1, nozzle_diameter: '0.4' }),
+          cal({ cali_idx: 7, nozzle_diameter: '0.6', k_value: 0.026, name: 'PLA 0.6' }),
+        ],
+      }),
+    ];
+    render(<Harness printers={withSpare} onProfiles={next => { latest = next; }} />);
+
+    fireEvent.change(screen.getByLabelText('X1C-1 Nozzle 0.6mm'), { target: { value: '7' } });
+    expect(latest.get(hotendKey(1, 0, '0.6'))?.cali_idx).toBe(7);
+  });
+});
+
+describe('PrinterProfilesSection — auto-match', () => {
+  it('fills every nozzle size of each model, preferring the variant for that size', () => {
+    let latest: Map<string, PresetChoice> = new Map();
+    render(<Harness onPresets={next => { latest = next; }} />);
+
+    fireEvent.click(screen.getByRole('button', { name: /auto-match/i }));
+
+    // The H2C list holds a plain "@BBL H2C" and an "@BBL H2C 0.2 nozzle". The
+    // 0.2 row takes the sized variant; the rest take the unsized one.
+    expect(latest.get(presetKey('H2C', '0.2'))?.code).toBe('GFSA21');
+    expect(latest.get(presetKey('H2C', '0.4'))?.code).toBe('GFSA09');
+    expect(latest.get(presetKey('H2C', '0.8'))?.code).toBe('GFSA09');
+    // Nothing is written to the model-wide key -- there is no row that shows
+    // it, and a stored value nobody can see is a value nobody can undo.
+    expect(latest.has(presetKey('H2C', ''))).toBe(false);
+  });
+
+  it('leaves a model with no matching variant inherited', () => {
+    let latest: Map<string, PresetChoice> = new Map();
+    render(<Harness onPresets={next => { latest = next; }} />);
+
+    fireEvent.click(screen.getByRole('button', { name: /auto-match/i }));
+
+    // P1S has no preset of this filament in the list, and an approximate one
+    // is worse than falling back to the spool's own.
+    for (const size of ['', '0.2', '0.4', '0.6', '0.8']) {
+      expect(latest.has(presetKey('P1S', size))).toBe(false);
+    }
+  });
+});
+
+describe('PrinterProfilesSection — K profiles', () => {
+  it('keys a chosen profile by printer, extruder and diameter', () => {
+    let latest: Map<string, CalibrationProfile> = new Map();
+    render(<Harness onProfiles={next => { latest = next; }} />);
+
+    // Addressed by cell rather than by DOM order: the grid is size down the
+    // side and hotend across, and only cells the printer has a calibration for
+    // hold a dropdown at all.
+    fireEvent.change(screen.getByLabelText('H2C-1 Left Nozzle 0.2mm'), {
+      target: { value: '16' },
+    });
+
+    // Extruder 1 at 0.2mm -- not the 0.4 hotend, and not keyed by cali_idx,
+    // which is numbered per nozzle and repeats across hotends.
+    expect(latest.get(hotendKey(3, 1, '0.2'))?.cali_idx).toBe(16);
+    expect(latest.has(hotendKey(3, 0, '0.4'))).toBe(false);
+  });
+
+  it('lays the grid out as nozzle size by hotend, with a dash where the printer has nothing', () => {
+    render(<Harness />);
+
+    // The H2C has a 0.2 profile on the left hotend and a 0.4 on the right.
+    expect(screen.getByLabelText('H2C-1 Left Nozzle 0.2mm')).toBeInTheDocument();
+    expect(screen.getByLabelText('H2C-1 Right Nozzle 0.4mm')).toBeInTheDocument();
+    // The other six cells have no calibration to offer, so no control.
+    expect(screen.queryByLabelText('H2C-1 Left Nozzle 0.4mm')).not.toBeInTheDocument();
+    expect(screen.queryByLabelText('H2C-1 Right Nozzle 0.8mm')).not.toBeInTheDocument();
+    // Every size is still listed down the side rather than looking forgotten.
+    for (const size of ['0.2mm', '0.4mm', '0.6mm', '0.8mm']) {
+      expect(screen.getAllByText(size).length).toBeGreaterThan(0);
+    }
+  });
+
+  it('gives a single-nozzle machine one unnamed column', () => {
+    render(<Harness />);
+    fireEvent.click(modelRow('X1C'));
+
+    expect(screen.getByLabelText('X1C-1 Nozzle 0.4mm')).toBeInTheDocument();
+    expect(screen.queryByLabelText(/X1C-1 (Left|Right) Nozzle/)).not.toBeInTheDocument();
+  });
+
+  it('choosing again on one hotend replaces rather than accumulates', () => {
+    let latest: Map<string, CalibrationProfile> = new Map();
+    render(<Harness onProfiles={next => { latest = next; }} />);
+
+    fireEvent.click(modelRow('X1C'));
+    const kSelect = screen.getByLabelText('X1C-1 Nozzle 0.4mm');
+
+    fireEvent.change(kSelect, { target: { value: '1' } });
+    fireEvent.change(kSelect, { target: { value: '' } });
+
+    expect(latest.size).toBe(0);
+  });
+
+  it('says an offline printer cannot be configured instead of showing empty rows', () => {
+    render(<Harness />);
+    fireEvent.click(modelRow('P1S'));
+    expect(screen.getByText(/printer is offline/i)).toBeInTheDocument();
+  });
+
+  it('labels the hotends of a dual-nozzle machine by side', () => {
+    render(<Harness />);
+    expect(screen.getByText('Right Nozzle')).toBeInTheDocument();
+    expect(screen.getByText('Left Nozzle')).toBeInTheDocument();
+
+    // A single-nozzle machine has no side to name.
+    fireEvent.click(modelRow('X1C'));
+    expect(screen.queryByText('Right Nozzle')).not.toBeInTheDocument();
+    expect(screen.getAllByText('Nozzle').length).toBeGreaterThan(0);
+  });
+});
+
+describe('PrinterProfilesSection — nozzle flow type', () => {
+  function flowFleet() {
+    return [
+      printer(1, 'H2D-1', 'H2D', {
+        nozzleCount: 2,
+        nozzles: [
+          { nozzle_diameter: '0.4', nozzle_type: 'HH01' },
+          { nozzle_diameter: '0.4', nozzle_type: 'HH01' },
+        ],
+        calibrations: [
+          cal({ cali_idx: 4, nozzle_id: 'HH00-0.4', name: 'High Flow_PLA' }),
+          cal({ cali_idx: 5, nozzle_id: 'HS00-0.4', name: 'Standard_PLA', k_value: 0.019 }),
+        ],
+      }),
+    ];
+  }
+
+  it('labels each profile with the flow it was measured on', () => {
+    // A printer can hold both for one diameter -- this H2D has 102 high-flow
+    // entries and 6 standard -- and the same filament reads a different K
+    // through each, so the list has to say which is which.
+    render(<Harness printers={flowFleet()} />);
+    const options = within(screen.getByLabelText('H2D-1 Right Nozzle 0.4mm'))
+      .getAllByRole('option')
+      .map(o => o.textContent ?? '');
+
+    expect(options.some(o => o.startsWith('[HF]'))).toBe(true);
+    expect(options.some(o => o.startsWith('[S]'))).toBe(true);
+  });
+
+  it('says nothing about flow when the printer declares none', () => {
+    // Measured on an X1C: every profile comes back with nozzle_id ''. A label
+    // there would be invented rather than reported.
+    render(<Harness />);
+    const options = within(screen.getByLabelText('H2C-1 Right Nozzle 0.4mm'))
+      .getAllByRole('option')
+      .map(o => o.textContent ?? '');
+
+    expect(options.some(o => o.includes('[HF]') || o.includes('[S]'))).toBe(false);
+  });
+
+  it('marks a chosen profile that does not match the fitted nozzle', () => {
+    // Standard profile chosen, high-flow nozzle fitted: the backend will not
+    // apply it, so the control must not look quietly configured.
+    const chosen = new Map([
+      [
+        hotendKey(1, 0, '0.4'),
+        cal({ cali_idx: 5, nozzle_id: 'HS00-0.4', name: 'Standard_PLA' }),
+      ],
+    ]);
+    render(<Harness printers={flowFleet()} profiles={chosen} />);
+
+    const select = screen.getByLabelText('H2D-1 Right Nozzle 0.4mm');
+    expect(select.getAttribute('title')).toMatch(/will not be applied/i);
+    expect(select.className).toContain('border-amber');
+  });
+
+  it('does not mark a profile whose flow agrees', () => {
+    const chosen = new Map([
+      [hotendKey(1, 0, '0.4'), cal({ cali_idx: 4, nozzle_id: 'HH00-0.4' })],
+    ]);
+    render(<Harness printers={flowFleet()} profiles={chosen} />);
+
+    const select = screen.getByLabelText('H2D-1 Right Nozzle 0.4mm');
+    expect(select.getAttribute('title')).toBeNull();
+  });
+});
+
+describe('PrinterProfilesSection — empty fleet', () => {
+  it('says so rather than rendering an empty two-pane layout', () => {
+    render(<Harness printers={[]} />);
+    expect(screen.getByText(/no printers configured/i)).toBeInTheDocument();
+  });
+
+  it('waits rather than claiming there are no printers while still loading', () => {
+    // Reading each printer's calibration table is several MQTT round trips, so
+    // this gap is seconds. Saying "no printers configured" during it is a wrong
+    // answer about the user's setup, not a slow one.
+    const Loading = () => {
+      const [presets, setPresets] = React.useState(new Map<string, PresetChoice>());
+      const [profiles, setProfiles] = React.useState(new Map<string, CalibrationProfile>());
+      const [group, setGroup] = React.useState('');
+      return (
+        <PrinterProfilesSection
+          formData={{ ...defaultFormData, material: 'PLA' }}
+          printersWithCalibrations={[]}
+          filamentOptions={OPTIONS}
+          modelPresets={presets}
+          setModelPresets={setPresets}
+          selectedProfiles={profiles}
+          setSelectedProfiles={setProfiles}
+          selectedGroupId={group}
+          setSelectedGroupId={setGroup}
+          isLoading
+        />
+      );
+    };
+    render(<Loading />);
+    expect(screen.queryByText(/no printers configured/i)).not.toBeInTheDocument();
+    expect(screen.getByText(/loading/i)).toBeInTheDocument();
+  });
+});
+
+describe('PrinterProfilesSection — no material yet', () => {
+  it('asks for a material before offering K profiles', () => {
+    const Bare = () => {
+      const [presets, setPresets] = React.useState(new Map<string, PresetChoice>());
+      const [profiles, setProfiles] = React.useState(new Map<string, CalibrationProfile>());
+      const [group, setGroup] = React.useState('');
+      return (
+        <PrinterProfilesSection
+          formData={{ ...defaultFormData, material: '' }}
+          printersWithCalibrations={fleet()}
+          filamentOptions={OPTIONS}
+          modelPresets={presets}
+          setModelPresets={setPresets}
+          selectedProfiles={profiles}
+          setSelectedProfiles={setProfiles}
+          selectedGroupId={group}
+          setSelectedGroupId={setGroup}
+        />
+      );
+    };
+    render(<Bare />);
+    expect(screen.getByText(/select a material first/i)).toBeInTheDocument();
+    // The preset half still works -- it does not depend on the material.
+    expect(screen.getByText('Filament preset')).toBeInTheDocument();
+  });
+});
+
+describe('PrinterProfilesSection — vi sanity', () => {
+  it('does not call any API of its own', () => {
+    // The section is presentational: everything it changes goes through the
+    // props, and SpoolFormModal is what persists it on save.
+    const spy = vi.fn();
+    render(<Harness onPresets={spy} />);
+    expect(spy).toHaveBeenCalled();
+  });
+});

+ 10 - 6
frontend/src/__tests__/components/SpoolFormBulk.test.tsx

@@ -5,7 +5,7 @@
  * - Quick-add toggle appears only in create mode
  * - Quick-add mode shows brand and subtype as optional (no asterisk)
  * - Quick-add mode hides slicer preset field
- * - Quick-add mode hides PA Profile tab
+ * - Quick-add mode hides the Printers tab
  * - Quantity field is only rendered in quick-add mode
  * - Quantity field is hidden in edit mode
  * - Bulk create calls bulkCreateSpools when quantity > 1
@@ -43,6 +43,10 @@ vi.mock('../../api/client', () => ({
     ]),
     updateSpool: vi.fn().mockResolvedValue({ id: 1 }),
     saveSpoolKProfiles: vi.fn().mockResolvedValue([]),
+    getSpoolFilamentPresets: vi.fn().mockResolvedValue([]),
+    saveSpoolFilamentPresets: vi.fn().mockResolvedValue([]),
+    getSpoolmanFilamentPresets: vi.fn().mockResolvedValue([]),
+    saveSpoolmanFilamentPresets: vi.fn().mockResolvedValue([]),
   },
 }));
 
@@ -175,7 +179,7 @@ describe('SpoolFormModal quick-add toggle', () => {
     expect(screen.queryByText('Quick Add (Stock)')).not.toBeInTheDocument();
   });
 
-  it('hides PA Profile tab when quick-add is enabled', async () => {
+  it('hides the Printers tab when quick-add is enabled', async () => {
     render(
       <SpoolFormModal
         isOpen={true}
@@ -189,8 +193,8 @@ describe('SpoolFormModal quick-add toggle', () => {
       expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
     });
 
-    // PA Profile tab should be visible initially
-    expect(screen.getByText('PA Profile')).toBeInTheDocument();
+    // Printers tab should be visible initially
+    expect(screen.getByText('Printers')).toBeInTheDocument();
 
     // Toggle quick-add on — the toggle is a button[role="switch"] sibling of the label
     const toggleButtons = screen.getAllByRole('button');
@@ -202,9 +206,9 @@ describe('SpoolFormModal quick-add toggle', () => {
     expect(quickAddToggle).toBeTruthy();
     fireEvent.click(quickAddToggle!);
 
-    // PA Profile tab should be hidden
+    // Printers tab should be hidden
     await waitFor(() => {
-      expect(screen.queryByText('PA Profile')).not.toBeInTheDocument();
+      expect(screen.queryByText('Printers')).not.toBeInTheDocument();
     });
   });
 

+ 8 - 4
frontend/src/__tests__/components/SpoolFormEditRelaxed.test.tsx

@@ -41,6 +41,10 @@ vi.mock('../../api/client', () => ({
     createSpool: vi.fn().mockResolvedValue({ id: 99 }),
     updateSpool: vi.fn().mockResolvedValue({ id: 7 }),
     saveSpoolKProfiles: vi.fn().mockResolvedValue([]),
+    getSpoolFilamentPresets: vi.fn().mockResolvedValue([]),
+    saveSpoolFilamentPresets: vi.fn().mockResolvedValue([]),
+    getSpoolmanFilamentPresets: vi.fn().mockResolvedValue([]),
+    saveSpoolmanFilamentPresets: vi.fn().mockResolvedValue([]),
     getSpoolmanInventoryFilaments: vi.fn().mockResolvedValue([]),
     getAssignments: vi.fn().mockResolvedValue([]),
     unassignSpool: vi.fn().mockResolvedValue({}),
@@ -175,8 +179,8 @@ describe('SpoolFormModal relaxed edit/copy validation (#1905)', () => {
 
     const presetInput = screen.getByPlaceholderText('Search filament presets...');
     fireEvent.focus(presetInput);
-    await waitFor(() => expect(screen.getByRole('button', { name: 'Generic ASA' })).toBeInTheDocument());
-    fireEvent.click(screen.getByRole('button', { name: 'Generic ASA' }));
+    await waitFor(() => expect(screen.getByRole('button', { name: /^Generic ASA\b/ })).toBeInTheDocument());
+    fireEvent.click(screen.getByRole('button', { name: /^Generic ASA\b/ }));
 
     // parsePresetName('Generic ASA') yields brand "Generic" — it must not
     // replace the manufacturer the spool already carries.
@@ -192,8 +196,8 @@ describe('SpoolFormModal relaxed edit/copy validation (#1905)', () => {
 
     const presetInput = screen.getByPlaceholderText('Search filament presets...');
     fireEvent.focus(presetInput);
-    await waitFor(() => expect(screen.getByRole('button', { name: 'Generic ASA' })).toBeInTheDocument());
-    fireEvent.click(screen.getByRole('button', { name: 'Generic ASA' }));
+    await waitFor(() => expect(screen.getByRole('button', { name: /^Generic ASA\b/ })).toBeInTheDocument());
+    fireEvent.click(screen.getByRole('button', { name: /^Generic ASA\b/ }));
 
     expect(screen.getByPlaceholderText('Search brand...')).toHaveValue('Generic');
     expect(screen.getByPlaceholderText('Select material...')).toHaveValue('ASA');

+ 63 - 3
frontend/src/__tests__/components/SpoolFormModal.test.tsx

@@ -31,6 +31,10 @@ vi.mock('../../api/client', () => ({
     createSpoolmanInventorySpool: vi.fn().mockResolvedValue({ id: 88 }),
     updateSpool: vi.fn().mockResolvedValue({ id: 1 }),
     saveSpoolKProfiles: vi.fn().mockResolvedValue([]),
+    getSpoolFilamentPresets: vi.fn().mockResolvedValue([]),
+    saveSpoolFilamentPresets: vi.fn().mockResolvedValue([]),
+    getSpoolmanFilamentPresets: vi.fn().mockResolvedValue([]),
+    saveSpoolmanFilamentPresets: vi.fn().mockResolvedValue([]),
     saveSpoolmanKProfiles: vi.fn().mockResolvedValue([]),
     updateSpoolmanInventorySpool: vi.fn().mockResolvedValue({ id: 42 }),
     bulkCreateSpoolmanInventorySpools: vi.fn().mockResolvedValue({
@@ -75,6 +79,18 @@ vi.mock('../../contexts/ToastContext', async (importOriginal) => {
 
 import { api } from '../../api/client';
 
+/**
+ * Open the spool form's "Color & Cost" tab.
+ *
+ * The form is split across three tabs -- Filament (identity + preset), Color &
+ * Cost (colour, spool weights, price, category, location) and Printers
+ * (per-model preset + per-hotend K profile). Fields that used to sit in one
+ * long scroll under Filament now need their tab opened first.
+ */
+function openColorAndCostTab() {
+  fireEvent.click(screen.getByText('Color & Cost'));
+}
+
 const existingSpool: InventorySpool = {
   id: 1,
   material: 'PLA',
@@ -159,6 +175,8 @@ describe('SpoolFormModal weightTouched', () => {
       expect(screen.getByText('Edit Spool')).toBeInTheDocument();
     });
 
+    openColorAndCostTab();
+
     // The remaining weight is (label_weight - weight_used) = 1000 - 300 = 700.
     // The input is a number input displaying 700. Find it by its displayed value.
     const remainingInput = screen.getByDisplayValue('700');
@@ -236,6 +254,8 @@ describe('SpoolFormModal weightTouched', () => {
       expect(screen.getByText('Edit Spool')).toBeInTheDocument();
     });
 
+    openColorAndCostTab();
+
     // Change the note field (unrelated to catalog ID)
     const noteInputs = screen.getAllByPlaceholderText(/note/i);
     expect(noteInputs.length).toBeGreaterThan(0);
@@ -278,6 +298,8 @@ describe('SpoolFormModal weightTouched', () => {
       expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
     });
 
+    openColorAndCostTab();
+
     // Wait for catalog to load
     await waitFor(() => {
       expect(api.getSpoolCatalog).toHaveBeenCalled();
@@ -579,6 +601,8 @@ describe('SpoolFormModal weightTouched', () => {
       expect(screen.getByText('Edit Spool')).toBeInTheDocument();
     });
 
+    openColorAndCostTab();
+
     // Wait for catalog to load
     await waitFor(() => {
       expect(api.getSpoolCatalog).toHaveBeenCalled();
@@ -631,7 +655,7 @@ describe('SpoolFormModal Spoolman K-profile support', () => {
     vi.clearAllMocks();
   });
 
-  it('shows PA Profile tab for Spoolman spools in non-quickAdd mode', async () => {
+  it('shows the Printers tab for Spoolman spools in non-quickAdd mode', async () => {
     render(
       <SpoolFormModal
         isOpen={true}
@@ -647,8 +671,8 @@ describe('SpoolFormModal Spoolman K-profile support', () => {
       expect(screen.getByText('Edit Spool')).toBeInTheDocument();
     });
 
-    // PA Profile tab should be visible in Spoolman mode
-    expect(screen.getByText('PA Profile')).toBeInTheDocument();
+    // Printers tab should be visible in Spoolman mode
+    expect(screen.getByText('Printers')).toBeInTheDocument();
   });
 
   it('calls saveSpoolmanKProfiles (not saveSpoolKProfiles) on update in Spoolman mode', async () => {
@@ -680,6 +704,34 @@ describe('SpoolFormModal Spoolman K-profile support', () => {
     });
     expect(api.saveSpoolKProfiles).not.toHaveBeenCalled();
   });
+
+  it('saves the per-model preset overrides alongside the K profiles', async () => {
+    // Both are full replacements and both are written on every save: that is
+    // how the user clears the last profile or the last override. The Spoolman
+    // pair must be the one called in Spoolman mode -- the two inventory modes
+    // have drifted apart on this path before (#1713).
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        spool={spoolmanSpool}
+        mode="edit"
+        currencySymbol="$"
+        spoolmanMode={true}
+      />
+    );
+
+    await waitFor(() => {
+      expect(screen.getByText('Edit Spool')).toBeInTheDocument();
+    });
+
+    fireEvent.click(screen.getByRole('button', { name: /save/i }));
+
+    await waitFor(() => {
+      expect(api.saveSpoolmanFilamentPresets).toHaveBeenCalledWith(42, []);
+    });
+    expect(api.saveSpoolFilamentPresets).not.toHaveBeenCalled();
+  });
 });
 
 // ---------------------------------------------------------------------------
@@ -888,10 +940,16 @@ describe('SpoolFormModal — SpoolmanFilamentPicker integration (T2)', () => {
       expect(screen.getByTestId('picker-selected-id').textContent).toBe('7');
     });
 
+    openColorAndCostTab();
+
     // Manually edit the color_name field (a linked field)
     const colorNameInput = screen.getByPlaceholderText('Jade White, Fire Red...');
     fireEvent.change(colorNameInput, { target: { value: 'Custom Blue' } });
 
+    // Back to the Filament tab: the catalog picker only renders there, so the
+    // link state has to be read where it lives.
+    fireEvent.click(screen.getByText('Filament Info'));
+
     // spoolman_filament_id must be cleared (picker shows 'none')
     await waitFor(() => {
       expect(screen.getByTestId('picker-selected-id').textContent).toBe('none');
@@ -1073,6 +1131,8 @@ describe('SpoolFormModal locationIdTouched', () => {
       expect(screen.getByText('Edit Spool')).toBeInTheDocument();
     });
 
+    openColorAndCostTab();
+
     // Change storage location via the catalog dropdown
     const locationSelect = screen.getByLabelText(/storage location/i);
     fireEvent.change(locationSelect, { target: { value: '2' } });

+ 118 - 0
frontend/src/__tests__/components/spool-form/fetchPrinterCalibrations.test.ts

@@ -0,0 +1,118 @@
+/**
+ * `fetchPrinterCalibrations` asks the printer for its K-profile table.
+ *
+ * Two properties, both measured on real hardware rather than reasoned about:
+ *
+ * 1. It asks for EVERY standard nozzle size, not only the sizes currently
+ *    fitted. A K profile is stored on the printer per diameter and survives a
+ *    nozzle swap, so fetching only what is screwed in right now hides a 0.6
+ *    profile until a 0.6 is fitted, and stops a spool being prepared for a
+ *    nozzle that is about to be changed to.
+ *
+ * 2. It asks for them ONE AT A TIME. H2-series firmware answers only the first
+ *    one or two of a concurrent burst of `extrusion_cali_get` and silently
+ *    drops the rest; each dropped request then costs a 5-second timeout before
+ *    the retry. Measured on an H2C and an H2D: four parallel requests took 11s
+ *    and 23s, against roughly 1s sent in series. An X1C answers all four
+ *    concurrently, which is why this stayed hidden while only dual-diameter
+ *    printers ever sent more than one request.
+ *
+ * The second is the one worth a test: it is invisible in every unit-level
+ * result (the same rows come back either way) and only shows up as a stall on
+ * one brand of hardware.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+const getKProfiles = vi.fn();
+
+vi.mock('../../../api/client', () => ({
+  api: {
+    get getKProfiles() {
+      return getKProfiles;
+    },
+  },
+}));
+
+import { fetchPrinterCalibrations } from '../../../components/spool-form/utils';
+import { STANDARD_NOZZLE_DIAMETERS } from '../../../components/spool-form/constants';
+
+function profile(slotId: number, diameter: string) {
+  return {
+    slot_id: slotId,
+    filament_id: 'GFL99',
+    setting_id: 'GFSL99',
+    name: `PLA ${diameter}`,
+    k_value: '0.020',
+    n_coef: '1.0',
+    extruder_id: 0,
+    nozzle_diameter: diameter,
+  };
+}
+
+describe('fetchPrinterCalibrations', () => {
+  beforeEach(() => {
+    getKProfiles.mockReset();
+  });
+
+  it('asks for every standard nozzle size, not just the fitted one', async () => {
+    getKProfiles.mockResolvedValue({ profiles: [] });
+
+    await fetchPrinterCalibrations(1, { nozzles: [{ nozzle_diameter: '0.4' }] });
+
+    const asked = getKProfiles.mock.calls.map(([, diameter]) => diameter);
+    expect(asked).toEqual(expect.arrayContaining(STANDARD_NOZZLE_DIAMETERS));
+  });
+
+  it('includes an unusual fitted diameter alongside the standard sizes', async () => {
+    getKProfiles.mockResolvedValue({ profiles: [] });
+
+    await fetchPrinterCalibrations(1, { nozzles: [{ nozzle_diameter: '1.0' }] });
+
+    const asked = getKProfiles.mock.calls.map(([, diameter]) => diameter);
+    expect(asked).toContain('1.0');
+    // Asked once each, no duplicate for a size that is both standard and fitted.
+    expect(new Set(asked).size).toBe(asked.length);
+  });
+
+  it('never has two requests in flight at once', async () => {
+    let inFlight = 0;
+    let maxInFlight = 0;
+    getKProfiles.mockImplementation(async () => {
+      inFlight++;
+      maxInFlight = Math.max(maxInFlight, inFlight);
+      await new Promise(resolve => setTimeout(resolve, 0));
+      inFlight--;
+      return { profiles: [] };
+    });
+
+    await fetchPrinterCalibrations(1, { nozzles: [{ nozzle_diameter: '0.4' }] });
+
+    expect(getKProfiles.mock.calls.length).toBeGreaterThan(1);
+    expect(maxInFlight).toBe(1);
+  });
+
+  it('keeps the diameters that answered when one request fails', async () => {
+    getKProfiles.mockImplementation(async (_id: number, diameter: string) => {
+      if (diameter === '0.6') throw new Error('printer said no');
+      return { profiles: [profile(1, diameter)] };
+    });
+
+    const rows = await fetchPrinterCalibrations(1, { nozzles: [{ nozzle_diameter: '0.4' }] });
+
+    const diameters = rows.map(r => r.nozzle_diameter);
+    expect(diameters).toContain('0.4');
+    expect(diameters).not.toContain('0.6');
+  });
+
+  it('flattens every size into one list of calibrations', async () => {
+    getKProfiles.mockImplementation(async (_id: number, diameter: string) => ({
+      profiles: diameter === '0.8' ? [] : [profile(Number(diameter.replace('.', '')), diameter)],
+    }));
+
+    const rows = await fetchPrinterCalibrations(1, { nozzles: [{ nozzle_diameter: '0.4' }] });
+
+    expect(rows.map(r => r.nozzle_diameter).sort()).toEqual(['0.2', '0.4', '0.6']);
+    expect(rows[0].k_value).toBeCloseTo(0.02);
+  });
+});

+ 66 - 2
frontend/src/api/client.ts

@@ -459,7 +459,13 @@ export interface ScheduledDrying {
 }
 
 export interface NozzleInfo {
-  nozzle_type: string;  // "stainless_steel" or "hardened_steel"
+  // Two vocabularies live in this field, by printer generation. Legacy
+  // printers (X1/P1) report the nozzle MATERIAL -- "stainless_steel",
+  // "hardened_steel". H2-series report a FLOW code -- "HH01" (high flow),
+  // "HS01" (standard); measured on an H2D. utils/nozzleFlow reads only the
+  // latter and treats anything else as "unknown", which is what makes the
+  // material spelling harmless here.
+  nozzle_type: string;
   nozzle_diameter: string;  // e.g., "0.4"
 }
 
@@ -566,7 +572,10 @@ export interface PrinterStatus {
   wifi_signal: number | null;  // WiFi signal strength in dBm
   wired_network: boolean;  // Ethernet connection detected
   door_open: boolean;  // Enclosure door open (models with a door sensor: X1/X1C/X1E/X2D/P2S/H2*)
-  nozzles: NozzleInfo[];  // Nozzle hardware info (index 0=left/primary, 1=right)
+  // Indexed by EXTRUDER id: [0] is the right hotend, [1] the left. Measured
+  // on an H2D with 0.4 left / 0.6 right. Read it through the helpers in
+  // utils/amsHelpers rather than indexing it directly.
+  nozzles: NozzleInfo[];
   nozzle_rack: NozzleRackSlot[];  // H2C 6-nozzle tool-changer rack
   print_options: PrintOptions | null;  // AI detection and print options
   // Calibration stage tracking
@@ -3628,6 +3637,38 @@ export interface SpoolKProfileInput {
   setting_id?: string | null;
 }
 
+/**
+ * One per-printer-model override of a spool's slicer filament preset.
+ *
+ * A cloud or Orca preset is bound to a printer model ("@BBL X1C"), so the
+ * spool's single `slicer_filament` is wrong as soon as the spool is used on a
+ * second model. `nozzle_diameter` is "" for the model's own default and a bare
+ * decimal ("0.2") for a per-hotend exception; the backend resolves
+ * exact (model, diameter) -> (model, "") -> the spool's own preset.
+ */
+export interface SlotSpoolDefaults {
+  slicer_filament: string | null;
+  slicer_filament_name: string | null;
+  cali_idx: number | null;
+  k_value: number | null;
+  profile_name: string | null;
+  extruder: number | null;
+  nozzle_diameter: string;
+}
+
+export interface SpoolFilamentPresetInput {
+  printer_model: string;
+  nozzle_diameter: string;
+  slicer_filament: string | null;
+  slicer_filament_name: string | null;
+}
+
+export interface SpoolFilamentPreset extends SpoolFilamentPresetInput {
+  id: number;
+  spool_id: number;
+  created_at: string;
+}
+
 /** One inventory-bound AMS slot, as returned by `/printers/{id}/inventory-remain`. */
 export interface SlotMaterial {
   ams_id: number;
@@ -6386,6 +6427,20 @@ export const api = {
       method: 'PUT',
       body: JSON.stringify(profiles),
     }),
+  /**
+   * What the spool assigned to this slot is configured to use *here* -- its
+   * per-printer-model filament preset and the K profile for this slot's hotend.
+   * Nulls when the slot holds no known spool.
+   */
+  getSlotSpoolDefaults: (printerId: number, amsId: number, trayId: number) =>
+    request<SlotSpoolDefaults>(`/printers/${printerId}/slots/${amsId}/${trayId}/spool-defaults`),
+  getSpoolFilamentPresets: (spoolId: number) =>
+    request<SpoolFilamentPreset[]>(`/inventory/spools/${spoolId}/filament-presets`),
+  saveSpoolFilamentPresets: (spoolId: number, presets: SpoolFilamentPresetInput[]) =>
+    request<SpoolFilamentPreset[]>(`/inventory/spools/${spoolId}/filament-presets`, {
+      method: 'PUT',
+      body: JSON.stringify(presets),
+    }),
   getAssignments: (printerId?: number) =>
     request<SpoolAssignment[]>(`/inventory/assignments${printerId ? `?printer_id=${printerId}` : ''}`),
   assignSpool: (data: { spool_id: number; printer_id: number; ams_id: number; tray_id: number }) =>
@@ -6628,6 +6683,15 @@ export const api = {
       body: JSON.stringify(profiles),
     }),
 
+  getSpoolmanFilamentPresets: (spoolId: number) =>
+    request<SpoolFilamentPreset[]>(`/spoolman/inventory/spools/${spoolId}/filament-presets`),
+
+  saveSpoolmanFilamentPresets: (spoolId: number, presets: SpoolFilamentPresetInput[]) =>
+    request<SpoolFilamentPreset[]>(`/spoolman/inventory/spools/${spoolId}/filament-presets`, {
+      method: 'PUT',
+      body: JSON.stringify(presets),
+    }),
+
   // Updates
   getVersion: () => request<VersionInfo>('/updates/version'),
   checkForUpdates: () => request<UpdateCheckResult>('/updates/check'),

+ 49 - 81
frontend/src/components/ConfigureAmsSlotModal.tsx

@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
 import { X, Loader2, Settings2, ChevronDown, CheckCircle2, RotateCcw } from 'lucide-react';
 import { api } from '../api/client';
 import type { KProfile } from '../api/client';
-import { matchesPrinterModelSuffix, presetCompatibility, buildCompatibilityIndex } from '../utils/slicerPrinterMatch';
+import { matchesPrinterModelSuffix, presetCompatibility, buildCompatibilityIndex, extractPresetModel } from '../utils/slicerPrinterMatch';
 import { toFilamentId } from './spool-form/utils';
 import { Button } from './Button';
 import { getAmsLabel } from '../utils/amsHelpers';
@@ -245,82 +245,6 @@ function colorNameToHex(name: string): string | null {
 }
 
 // Escape regex metacharacters and turn whitespace into ``\s+`` so a literal
-// model token compiles to a flexible-whitespace word-boundary regex.
-function _tokenToRegex(token: string): RegExp {
-  const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+');
-  return new RegExp(`\\b${escaped}\\b`, 'i');
-}
-
-// Extract printer model from a preset name → normalized short code
-// (e.g. "X1C", "H2D"). Two strategies in order:
-//
-// (1) ``@`` suffix — the BambuStudio naming convention. Two shapes:
-//   - "@BBL X1C 0.4 nozzle"               → "X1C"  (short-code form,
-//      Bambu Cloud system presets)
-//   - "@Bambu Lab X1 Carbon 0.4 nozzle"   → "X1C"  (long-form, used by
-//      user-renamed Bambu Cloud presets and most Orca Cloud profiles —
-//      reverse-looked-up via the backend printer-model registry)
-//
-// (2) Body scan — many user-authored / Orca Cloud presets put the printer
-// model at the START of the name with no @ suffix at all (the literal
-// shape that surfaced #1623: "X1C eSUN PETG-Basic Filament"). Scan the
-// name for any known model token (every long-name fragment + every short
-// code from the registry) and return the first match. Long-first sort
-// keeps "A1 Mini" / "X1 Carbon" / "H2D Pro" from being eaten by their
-// shorter sibling ("A1" / "X1" / "H2D"). Word-boundary regex prevents
-// false-positives on partial substrings (e.g. "PA1" doesn't match "A1",
-// "X1Box" doesn't match "X1").
-//
-// Returns null when neither strategy resolves; the caller keeps such
-// presets visible (can't filter what we can't classify).
-//
-// ``printerModelsLongToShort`` is the backend's PRINTER_MODEL_MAP shape:
-// keys are "Bambu Lab <long>", values are short codes.
-function extractPresetModel(
-  name: string,
-  printerModelsLongToShort: Record<string, string>,
-): string | null {
-  const atIdx = name.indexOf('@');
-  if (atIdx >= 0) {
-    const suffix = name.slice(atIdx + 1).trim();
-    const bblMatch = suffix.match(/^BBL\s+(.+?)(?:\s+[\d.]+\s*nozzle)?$/i);
-    if (bblMatch) return bblMatch[1].trim();
-    const longMatch = suffix.match(/^Bambu Lab\s+(.+?)(?:\s+[\d.]+\s*nozzle)?$/i);
-    if (longMatch) {
-      const longFragment = longMatch[1].trim();
-      const fullKey = `Bambu Lab ${longFragment}`;
-      if (printerModelsLongToShort[fullKey]) return printerModelsLongToShort[fullKey];
-      const lower = fullKey.toLowerCase();
-      for (const [k, v] of Object.entries(printerModelsLongToShort)) {
-        if (k.toLowerCase() === lower) return v;
-      }
-      return longFragment;
-    }
-  }
-
-  // Body scan — accumulate {token, short} pairs and try long-first.
-  const tokens: Array<{ token: string; short: string }> = [];
-  const seen = new Set<string>();
-  for (const [longName, short] of Object.entries(printerModelsLongToShort)) {
-    const fragment = longName.replace(/^Bambu Lab\s+/, '');
-    const key = fragment.toLowerCase();
-    if (!seen.has(key)) {
-      tokens.push({ token: fragment, short });
-      seen.add(key);
-    }
-    const shortKey = short.toLowerCase();
-    if (!seen.has(shortKey)) {
-      tokens.push({ token: short, short });
-      seen.add(shortKey);
-    }
-  }
-  tokens.sort((a, b) => b.token.length - a.token.length);
-  for (const { token, short } of tokens) {
-    if (_tokenToRegex(token).test(name)) return short;
-  }
-  return null;
-}
-
 export function ConfigureAmsSlotModal({
   isOpen,
   onClose,
@@ -415,6 +339,19 @@ export function ConfigureAmsSlotModal({
     staleTime: Infinity,
   });
 
+  // What the spool in this slot is configured to use here: its filament preset
+  // for this printer's MODEL and its K profile for this slot's hotend. Those
+  // are the values the user set on the spool, so they are the right defaults
+  // for a dialog that configures the slot that spool sits in -- the slot's own
+  // last manual configuration and the tray's RFID data are the fallbacks, not
+  // the other way round.
+  const { data: slotSpoolDefaults } = useQuery({
+    queryKey: ['slot-spool-defaults', printerId, slotInfo.amsId, slotInfo.trayId],
+    queryFn: () => api.getSlotSpoolDefaults(printerId, slotInfo.amsId, slotInfo.trayId),
+    enabled: isOpen,
+    staleTime: 0,
+  });
+
   const compatIndex = useMemo(
     () => buildCompatibilityIndex(printerModelsData ?? {}),
     [printerModelsData],
@@ -1076,8 +1013,10 @@ export function ConfigureAmsSlotModal({
   // Pre-select current profile when modal opens, reset when closes
   useEffect(() => {
     if (isOpen) {
-      // Pre-populate from saved preset mapping (most reliable)
-      if (slotInfo.savedPresetId) {
+      // The spool's own per-model preset first -- see the query above.
+      if (slotSpoolDefaults?.slicer_filament) {
+        setSelectedPresetId(slotSpoolDefaults.slicer_filament);
+      } else if (slotInfo.savedPresetId) {
         setSelectedPresetId(slotInfo.savedPresetId);
       } else if (slotInfo.trayInfoIdx && cloudSettings?.filament) {
         // Fallback: try to match by tray_info_idx in cloud presets
@@ -1120,11 +1059,33 @@ export function ConfigureAmsSlotModal({
       setShowSuccess(false);
       scrolledToRef.current = '';
     }
-  }, [isOpen, slotInfo.savedPresetId, slotInfo.trayInfoIdx, slotInfo.trayColor, cloudSettings?.filament, builtinFilaments]);
+  }, [
+    isOpen,
+    slotSpoolDefaults?.slicer_filament,
+    slotInfo.savedPresetId,
+    slotInfo.trayInfoIdx,
+    slotInfo.trayColor,
+    cloudSettings?.filament,
+    builtinFilaments,
+  ]);
 
   // Auto-select best matching K profile when preset changes
   useEffect(() => {
     if (matchingKProfiles.length > 0) {
+      // The profile the spool is configured with for THIS hotend, if it still
+      // exists on the printer. Ahead of the slot's live cali_idx, which is
+      // whatever was selected last rather than what the spool is set to.
+      if (slotSpoolDefaults?.cali_idx != null) {
+        const configured = findProfileByCaliIdx(
+          matchingKProfiles,
+          slotSpoolDefaults.cali_idx,
+          slotSpoolDefaults.extruder ?? slotInfo.extruderId,
+        );
+        if (configured) {
+          setSelectedKProfile(configured);
+          return;
+        }
+      }
       // Prefer the currently-active K-profile, resolved against this slot's own
       // nozzle — the index alone is ambiguous across hotends.
       if (slotInfo.caliIdx != null && slotInfo.caliIdx > 0) {
@@ -1141,7 +1102,14 @@ export function ConfigureAmsSlotModal({
     } else {
       setSelectedKProfile(null);
     }
-  }, [selectedPresetId, matchingKProfiles, slotInfo.caliIdx, slotInfo.extruderId]);
+  }, [
+    selectedPresetId,
+    matchingKProfiles,
+    slotSpoolDefaults?.cali_idx,
+    slotSpoolDefaults?.extruder,
+    slotInfo.caliIdx,
+    slotInfo.extruderId,
+  ]);
 
   // Escape key handler
   const handleKeyDown = useCallback((e: KeyboardEvent) => {

+ 235 - 104
frontend/src/components/SpoolFormModal.tsx

@@ -3,25 +3,33 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { X, Loader2, Save, Beaker, Palette, Zap, Tag, Unlink } from 'lucide-react';
 import { api, ApiError } from '../api/client';
-import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset, BuiltinFilament, SpoolmanBulkCreateResult, SpoolKProfileInput, SpoolmanFilamentEntry } from '../api/client';
+import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset, BuiltinFilament, SpoolmanBulkCreateResult, SpoolFilamentPresetInput, SpoolKProfileInput, SpoolmanFilamentEntry } from '../api/client';
 import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
-import type { SpoolFormData, PrinterWithCalibrations, ColorPreset, SpoolFormMode } from './spool-form/types';
+import type {
+  CalibrationProfile,
+  ColorPreset,
+  PresetChoice,
+  PrinterWithCalibrations,
+  SpoolFormData,
+  SpoolFormMode,
+} from './spool-form/types';
 import { defaultFormData, validateForm, SPOOLMAN_LINKED_FIELDS } from './spool-form/types';
-import { buildFilamentOptions, extractBrandsFromPresets, fetchPrinterCalibrations, findPresetOption, loadRecentColors, pairedOptions, parsePresetName, saveRecentColor, withCurrentValue } from './spool-form/utils';
+import { buildFilamentOptions, extractBrandsFromPresets, fetchPrinterCalibrations, findPresetOption, hotendKey, loadRecentColors, pairedOptions, parsePresetKey, parsePresetName, presetKey, saveRecentColor, withCurrentValue } from './spool-form/utils';
 import { MATERIALS } from './spool-form/constants';
 import { FilamentSection } from './spool-form/FilamentSection';
 import { ColorSection } from './spool-form/ColorSection';
 import { AdditionalSection } from './spool-form/AdditionalSection';
 import { SpoolmanFilamentPicker } from './spool-form/SpoolmanFilamentPicker';
-import { PAProfileSection } from './spool-form/PAProfileSection';
+import { PrinterProfilesSection } from './spool-form/PrinterProfilesSection';
+import { normaliseFlow } from '../utils/nozzleFlow';
 import { SpoolUsageHistory } from './SpoolUsageHistory';
 import {
   invalidateInventoryLocations,
   invalidateSpoolAndLocationQueries,
 } from '../utils/inventoryQueries';
 
-type TabId = 'filament' | 'pa-profile';
+type TabId = 'filament' | 'appearance' | 'printers';
 
 const CLEAR_TAG_PAYLOAD = { tag_uid: null, tray_uuid: null, tag_type: null, data_origin: null };
 
@@ -75,6 +83,7 @@ export function SpoolFormModal({
   const [cloudAuthenticated, setCloudAuthenticated] = useState(false);
   const [loadingCloudPresets, setLoadingCloudPresets] = useState(false);
   const [cloudPresets, setCloudPresets] = useState<SlicerSetting[]>([]);
+  const [orcaSettingIds, setOrcaSettingIds] = useState<Set<string>>(new Set());
   const [presetInputValue, setPresetInputValue] = useState('');
 
   // Spool catalog
@@ -104,16 +113,31 @@ export function SpoolFormModal({
 
   // PA Profile state
   const [fetchedCalibrations, setFetchedCalibrations] = useState<PrinterWithCalibrations[]>([]);
-  const [selectedProfiles, setSelectedProfiles] = useState<Set<string>>(new Set());
-  const [expandedPrinters, setExpandedPrinters] = useState<Set<string>>(new Set());
+  // Whether the calibration fetch above is still running. Without it the
+  // Printers tab renders its "no printers configured" empty state while the
+  // printers are still being asked -- which reads as a wrong answer rather
+  // than as a wait, and the fetch is several round trips per machine.
+  const [loadingCalibrations, setLoadingCalibrations] = useState(false);
+  // One K profile per hotend, keyed `printerId:extruder:diameter`. A Map rather
+  // than a Set of composite keys because the Printers tab presents each hotend
+  // as a single-choice dropdown -- the shape makes "two profiles for one
+  // hotend" unrepresentable instead of relying on eviction logic to prevent it.
+  const [selectedProfiles, setSelectedProfiles] = useState<Map<string, CalibrationProfile>>(new Map());
+  // Per-printer-model preset overrides, keyed by `presetKey(model, diameter)`.
+  // Only the models the user has actually overridden are present; an absent
+  // entry means "inherit this spool's own preset", which is exactly what the
+  // backend cascade does with a missing row.
+  const [modelPresets, setModelPresets] = useState<Map<string, PresetChoice>>(new Map());
+  const [selectedGroupId, setSelectedGroupId] = useState<string>('');
 
   // Use prop if provided, otherwise use self-fetched data
   const resolvedCalibrations = printersWithCalibrations.length > 0
     ? printersWithCalibrations
     : fetchedCalibrations;
 
-  // Count selected PA profiles for tab badge
-  const selectedProfileCount = selectedProfiles.size;
+  // Tab badge: everything the user has configured under Printers, K profiles
+  // and preset overrides alike, since both live on that tab now.
+  const selectedProfileCount = selectedProfiles.size + modelPresets.size;
 
   // Fetch Spoolman filament catalog when in Spoolman mode
   // retry:false — Spoolman may be intentionally disabled (400); don't flood the server
@@ -169,6 +193,10 @@ export function SpoolFormModal({
           const orcaPresets = orcaResult.status === 'fulfilled' ? orcaResult.value.presets : [];
           setCloudAuthenticated(bambuConnected || orcaConnected);
           setCloudPresets([...bambuPresets, ...orcaPresets]);
+          // The two clouds are merged into one list, so remember which ids came
+          // from Orca -- it is the only way the origin badge can tell them
+          // apart afterwards.
+          setOrcaSettingIds(new Set(orcaPresets.map(p => p.setting_id)));
         } catch (e) {
           if (cancelled) return;
           console.error('Failed to fetch cloud presets:', e);
@@ -189,27 +217,40 @@ export function SpoolFormModal({
       // Fetch printer calibrations if not provided via props
       if (printersWithCalibrations.length === 0) {
         (async () => {
+          setLoadingCalibrations(true);
           try {
             const printers = await api.getPrinters();
             const statuses = await Promise.all(
               printers.map(p => api.getPrinterStatus(p.id).catch(() => null)),
             );
-            const results: PrinterWithCalibrations[] = [];
-            for (let i = 0; i < printers.length; i++) {
-              const printer = printers[i];
-              const status = statuses[i];
-              const connected = status?.connected ?? false;
-              let calibrations: PrinterWithCalibrations['calibrations'] = [];
-              if (connected) {
-                // Fetch across every installed nozzle so dual-nozzle printers
-                // surface both the 0.4mm and 0.6mm K-profiles, not just 0.4 (#2618).
-                calibrations = await fetchPrinterCalibrations(printer.id, status);
-              }
-              results.push({ printer: { ...printer, connected }, calibrations });
-            }
-            setFetchedCalibrations(results);
+            // Printers in parallel, diameters within a printer in series.
+            // Separate machines are separate MQTT connections and do not
+            // interfere; it is one printer's own firmware that drops a
+            // concurrent burst of calibration requests (see
+            // fetchPrinterCalibrations). Walking the fleet one machine at a
+            // time made the whole tab wait for the sum of every printer.
+            const results = await Promise.all(
+              printers.map(async (printer, i) => {
+                const status = statuses[i];
+                const connected = status?.connected ?? false;
+                let calibrations: PrinterWithCalibrations['calibrations'] = [];
+                if (connected) {
+                  // Across every nozzle size, so a profile for a size that is
+                  // not currently fitted is still offered (#2618 fetched only
+                  // the fitted ones).
+                  calibrations = await fetchPrinterCalibrations(printer.id, status);
+                }
+                // Keep the reported nozzle hardware: the Printers tab lists a
+                // model's installed diameters from it. Read as a set of
+                // diameters only -- never indexed by extruder.
+                return { printer: { ...printer, connected }, calibrations, nozzles: status?.nozzles };
+              }),
+            );
+            if (!cancelled) setFetchedCalibrations(results);
           } catch (e) {
             console.error('Failed to fetch printer calibrations:', e);
+          } finally {
+            if (!cancelled) setLoadingCalibrations(false);
           }
         })();
       }
@@ -228,8 +269,8 @@ export function SpoolFormModal({
 
   // Build filament options: cloud → local → fallback
   const filamentOptions = useMemo(
-    () => buildFilamentOptions(cloudPresets, new Set(), localPresets, builtinFilaments),
-    [cloudPresets, localPresets, builtinFilaments],
+    () => buildFilamentOptions(cloudPresets, new Set(), localPresets, builtinFilaments, orcaSettingIds),
+    [cloudPresets, localPresets, builtinFilaments, orcaSettingIds],
   );
 
   // Extract brands from presets
@@ -374,22 +415,37 @@ export function SpoolFormModal({
         });
         setPresetInputValue(spool.slicer_filament_name || spool.slicer_filament || '');
 
-        // Load K-profiles for this spool
+        // Load K-profiles for this spool. The stored row carries everything
+        // the picker needs to show the selection before the printer answers,
+        // so an offline printer still renders what was chosen for it.
         if (spool.k_profiles && spool.k_profiles.length > 0) {
-          const profileKeys = new Set<string>();
+          const chosen = new Map<string, CalibrationProfile>();
           for (const p of spool.k_profiles) {
-            if (p.cali_idx !== null && p.cali_idx !== undefined) {
-              profileKeys.add(`${p.printer_id}:${p.cali_idx}:${p.extruder ?? 'null'}`);
-            }
+            if (p.cali_idx === null || p.cali_idx === undefined) continue;
+            const diameter = (p.nozzle_diameter || '').trim() || '0.4';
+            const extruder = p.extruder ?? 0;
+            chosen.set(hotendKey(p.printer_id, extruder, diameter), {
+              cali_idx: p.cali_idx,
+              filament_id: '',
+              setting_id: p.setting_id || '',
+              name: p.name || '',
+              k_value: p.k_value,
+              n_coef: 0,
+              extruder_id: extruder,
+              nozzle_diameter: diameter,
+              // Stored as the bare flow code; the picker re-derives its label
+              // from the same field it reads off a live profile.
+              nozzle_id: p.nozzle_type || '',
+            });
           }
-          setSelectedProfiles(profileKeys);
+          setSelectedProfiles(chosen);
         } else {
-          setSelectedProfiles(new Set());
+          setSelectedProfiles(new Map());
         }
       } else {
         setFormData(defaultFormData);
         setPresetInputValue('');
-        setSelectedProfiles(new Set());
+        setSelectedProfiles(new Map());
       }
       // Reset on every open, not just the create path (#1905). The modal keeps
       // its state while closed, and the Quick Add toggle only renders in create
@@ -400,11 +456,51 @@ export function SpoolFormModal({
       setQuantity(1);
       setErrors({});
       setActiveTab('filament');
+      setSelectedGroupId('');
+      // Cleared on every open, both branches: the modal keeps its state while
+      // closed, so editing spool B after spool A would otherwise show (and
+      // save) A's per-model overrides on B. Refilled by the fetch below.
+      setModelPresets(new Map());
       setWeightTouched(false);
       setLocationIdTouched(false);
     }
   }, [isOpen, spool, mode, isCopying]);
 
+  // Load this spool's per-printer-model preset overrides. Fetched rather than
+  // read off the spool: they are deliberately not embedded in the spool
+  // response, which the inventory list returns once per spool the user owns.
+  // Copying a spool copies its overrides -- they describe the filament on the
+  // spool, which is what a copy has too.
+  useEffect(() => {
+    if (!isOpen || !spool) return;
+    let cancelled = false;
+    const load = spoolmanMode ? api.getSpoolmanFilamentPresets : api.getSpoolFilamentPresets;
+    load(spool.id)
+      .then(rows => {
+        if (cancelled) return;
+        const next = new Map<string, PresetChoice>();
+        for (const row of rows) {
+          next.set(presetKey(row.printer_model, row.nozzle_diameter || ''), {
+            code: row.slicer_filament || '',
+            name: row.slicer_filament_name || '',
+          });
+        }
+        setModelPresets(next);
+      })
+      .catch(e => {
+        // Non-fatal: the tab still works, it just starts with nothing
+        // overridden. Saving from that state WOULD clear the stored rows, so
+        // say so rather than letting the user save over what they cannot see.
+        if (cancelled) return;
+        console.error('Failed to load filament preset overrides:', e);
+        showToast(t('inventory.filamentPresetsLoadFailed'), 'warning');
+      });
+    return () => {
+      cancelled = true;
+    };
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [isOpen, spool?.id, spoolmanMode]);
+
   // Legacy rows may have storage_location text but no location_id yet — link when catalog loads.
   useEffect(() => {
     if (!isOpen || !spool || locationIdTouched || formData.location_id != null) return;
@@ -416,12 +512,6 @@ export function SpoolFormModal({
     }
   }, [isOpen, spool, storageLocations, formData.location_id, locationIdTouched]);
 
-  // Expand all printers in PA profile section when calibrations are available
-  useEffect(() => {
-    if (isOpen && resolvedCalibrations.length > 0) {
-      setExpandedPrinters(new Set(resolvedCalibrations.map(p => String(p.printer.id))));
-    }
-  }, [isOpen, resolvedCalibrations]);
 
   // Update field helper
   const updateField = <K extends keyof SpoolFormData>(key: K, value: SpoolFormData[K]) => {
@@ -481,7 +571,7 @@ export function SpoolFormModal({
         : api.createSpool(data as Parameters<typeof api.createSpool>[0]),
     onSuccess: async (newSpool) => {
       if (newSpool?.id) {
-        const ok = await saveKProfiles(newSpool.id);
+        const ok = await savePrinterProfiles(newSpool.id);
         if (!ok) return;
       }
       await refreshSpoolQueries();
@@ -518,9 +608,12 @@ export function SpoolFormModal({
         ? spoolmanResult.created
         : (result as InventorySpool[]);
 
-      if (selectedProfiles.size > 0) {
+      // Bulk create: every copy gets the same profiles and overrides. Skipped
+      // entirely when the user configured neither, so a plain bulk add does
+      // not fire two writes per spool.
+      if (selectedProfiles.size > 0 || modelPresets.size > 0) {
         for (const s of createdSpools) {
-          await saveKProfiles(s.id);
+          await savePrinterProfiles(s.id);
         }
       }
       await refreshSpoolQueries();
@@ -554,7 +647,7 @@ export function SpoolFormModal({
         : api.updateSpool(spool!.id, data as Parameters<typeof api.updateSpool>[1]),
     onSuccess: async () => {
       if (spool?.id) {
-        const ok = await saveKProfiles(spool.id);
+        const ok = await savePrinterProfiles(spool.id);
         if (!ok) return;
       }
       await refreshSpoolQueries();
@@ -625,6 +718,18 @@ export function SpoolFormModal({
     queryFn: api.getSettings,
     enabled: isOpen,
   });
+
+  // Backend Bambu printer-model registry, so the Printers tab can read the
+  // model out of a preset name and offer each model only its own presets. The
+  // same query key and staleTime the Configure AMS Slot modal uses -- the
+  // registry only changes across backend releases, so this is a cache hit
+  // whenever that modal has been opened.
+  const { data: printerModelsData } = useQuery({
+    queryKey: ['slicerPrinterModels'],
+    queryFn: api.getSlicerPrinterModels,
+    enabled: isOpen,
+    staleTime: Infinity,
+  });
   const availableCategories = (() => {
     const set = new Set<string>();
     for (const s of allSpools ?? []) {
@@ -660,65 +765,66 @@ export function SpoolFormModal({
     },
   });
 
-  // Save K-profiles for selected calibrations. Returns false if any error occurred.
-  const saveKProfiles = async (spoolId: number): Promise<boolean> => {
-    const saveApi = spoolmanMode ? api.saveSpoolmanKProfiles : api.saveSpoolKProfiles;
-
-    if (selectedProfiles.size === 0) {
-      try {
-        await saveApi(spoolId, []);
-        return true;
-      } catch (e) {
-        console.error('Failed to save K-profiles:', e);
-        showToast(t('inventory.kProfileSaveFailed'), 'warning');
-        return false;
-      }
+  // Save everything the Printers tab holds: one K profile per hotend and the
+  // per-printer-model preset overrides. Returns false if either write failed,
+  // which keeps the modal open so the user does not lose what they picked.
+  const savePrinterProfiles = async (spoolId: number): Promise<boolean> => {
+    const saveKApi = spoolmanMode ? api.saveSpoolmanKProfiles : api.saveSpoolKProfiles;
+    const savePresetApi = spoolmanMode ? api.saveSpoolmanFilamentPresets : api.saveSpoolFilamentPresets;
+
+    // The selection Map is keyed by hotend and holds the calibration itself,
+    // so nothing has to be resolved back out of the printer's live list. That
+    // also fixes a real defect in the old key-based lookup: it matched a
+    // calibration by cali_idx alone, and cali_idx is numbered PER NOZZLE --
+    // on a dual-nozzle printer it could resolve the other hotend's entry and
+    // persist that entry's K value and diameter.
+    const profiles: SpoolKProfileInput[] = [];
+    for (const [key, cal] of selectedProfiles) {
+      const [printerIdStr, extruderStr, diameter] = key.split(':');
+      profiles.push({
+        printer_id: parseInt(printerIdStr),
+        extruder: parseInt(extruderStr),
+        nozzle_diameter: diameter || '0.4',
+        // The flow the profile was measured on, when the printer declares one.
+        // Null where it does not (an X1C declares none on any profile), which
+        // the backend reads as "applies to whatever is fitted" -- the same rule
+        // that keeps every profile stored before this working.
+        nozzle_type: normaliseFlow(cal.nozzle_id),
+        k_value: cal.k_value,
+        name: cal.name || null,
+        cali_idx: cal.cali_idx,
+        setting_id: cal.setting_id || null,
+      });
     }
 
-    const profiles: SpoolKProfileInput[] = [];
-    let dropped = 0;
-    for (const key of selectedProfiles) {
-      const [printerIdStr, caliIdxStr, extruderStr] = key.split(':');
-      const printerId = parseInt(printerIdStr);
-      const caliIdx = parseInt(caliIdxStr);
-      const extruder = extruderStr === 'null' ? 0 : parseInt(extruderStr);
-
-      const pc = resolvedCalibrations.find(p => p.printer.id === printerId);
-      if (pc) {
-        const cal = pc.calibrations.find(c => c.cali_idx === caliIdx);
-        if (cal) {
-          profiles.push({
-            printer_id: printerId,
-            extruder,
-            nozzle_diameter: cal.nozzle_diameter || '0.4',
-            k_value: cal.k_value,
-            name: cal.name || null,
-            cali_idx: cal.cali_idx,
-            setting_id: cal.setting_id || null,
-          });
-        } else {
-          dropped++;
-        }
-      } else {
-        dropped++;
-      }
+    const presets: SpoolFilamentPresetInput[] = [];
+    for (const [key, choice] of modelPresets) {
+      const { model, diameter } = parsePresetKey(key);
+      if (!model) continue;
+      presets.push({
+        printer_model: model,
+        nozzle_diameter: diameter,
+        slicer_filament: choice.code || null,
+        slicer_filament_name: choice.name || null,
+      });
     }
 
-    if (dropped > 0) {
-      console.error(`saveKProfiles: ${dropped} profile key(s) could not be resolved`, Array.from(selectedProfiles));
+    // Both are full replacements, so both run even when empty -- that is how
+    // the user clears the last profile or the last override.
+    try {
+      await saveKApi(spoolId, profiles);
+    } catch (e) {
+      console.error('Failed to save K-profiles:', e);
       showToast(t('inventory.kProfileSaveFailed'), 'warning');
       return false;
     }
 
-    if (profiles.length > 0) {
-      try {
-        await saveApi(spoolId, profiles);
-        return true;
-      } catch (e) {
-        console.error('Failed to save K-profiles:', e);
-        showToast(t('inventory.kProfileSaveFailed'), 'warning');
-        return false;
-      }
+    try {
+      await savePresetApi(spoolId, presets);
+    } catch (e) {
+      console.error('Failed to save filament preset overrides:', e);
+      showToast(t('inventory.filamentPresetSaveFailed'), 'warning');
+      return false;
     }
 
     return true;
@@ -800,7 +906,12 @@ export function SpoolFormModal({
         onClick={onClose}
       />
 
-      <div className="relative w-full max-w-xl mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-h-[90vh] flex flex-col">
+      {/* Wider than the old max-w-xl: the Printers tab is a model list beside
+          a detail pane holding a preset row per nozzle size and a hotend-by-
+          size grid, which needs room for both. Held constant across tabs
+          rather than sized per tab -- a modal that resizes as you switch tabs
+          reads as a layout bug. */}
+      <div className="relative w-full max-w-5xl mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-h-[90vh] flex flex-col">
         {/* Header */}
         <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary flex-shrink-0">
           <h2 className="text-lg font-semibold text-white flex items-baseline gap-2">
@@ -857,20 +968,33 @@ export function SpoolFormModal({
                 : 'text-bambu-gray hover:text-white'
             }`}
           >
-            <Palette className="w-4 h-4" />
+            <Beaker className="w-4 h-4" />
             {t('inventory.filamentInfoTab')}
           </button>
           {!quickAdd && (
             <button
-              onClick={() => setActiveTab('pa-profile')}
+              onClick={() => setActiveTab('appearance')}
+              className={`flex-1 px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 transition-colors ${
+                activeTab === 'appearance'
+                  ? 'text-bambu-green border-b-2 border-bambu-green'
+                  : 'text-bambu-gray hover:text-white'
+              }`}
+            >
+              <Palette className="w-4 h-4" />
+              {t('inventory.colorAndCostTab')}
+            </button>
+          )}
+          {!quickAdd && (
+            <button
+              onClick={() => setActiveTab('printers')}
               className={`flex-1 px-4 py-2.5 text-sm font-medium flex items-center justify-center gap-2 transition-colors ${
-                activeTab === 'pa-profile'
+                activeTab === 'printers'
                   ? 'text-bambu-green border-b-2 border-bambu-green'
                   : 'text-bambu-gray hover:text-white'
               }`}
             >
-              <Beaker className="w-4 h-4" />
-              {t('inventory.paProfileTab')}
+              <Zap className="w-4 h-4" />
+              {t('inventory.printersTab')}
               {selectedProfileCount > 0 && (
                 <span className="text-xs px-1.5 py-0.5 rounded-full bg-bambu-green/20 text-bambu-green">
                   {selectedProfileCount}
@@ -926,6 +1050,9 @@ export function SpoolFormModal({
                 />
               </div>
 
+            </div>
+          ) : activeTab === 'appearance' ? (
+            <div className="space-y-6">
               {/* Color Section */}
               <div>
                 <h3 className="text-sm font-semibold text-bambu-gray uppercase tracking-wide mb-3">
@@ -981,14 +1108,18 @@ export function SpoolFormModal({
               )}
             </div>
           ) : (
-            <PAProfileSection
+            <PrinterProfilesSection
               formData={formData}
-              updateField={updateField}
               printersWithCalibrations={resolvedCalibrations}
+              filamentOptions={filamentOptions}
+              modelPresets={modelPresets}
+              setModelPresets={setModelPresets}
               selectedProfiles={selectedProfiles}
               setSelectedProfiles={setSelectedProfiles}
-              expandedPrinters={expandedPrinters}
-              setExpandedPrinters={setExpandedPrinters}
+              selectedGroupId={selectedGroupId}
+              setSelectedGroupId={setSelectedGroupId}
+              printerModels={printerModelsData}
+              isLoading={loadingCalibrations}
             />
           )}
         </div>

+ 30 - 23
frontend/src/components/spool-form/AdditionalSection.tsx

@@ -204,19 +204,26 @@ export function AdditionalSection({
   }, [isRemainingFocused, remainingWeight]);
 
   return (
-    <div className="space-y-4">
+    // Two columns from sm up. These are all short single-value fields, and at
+    // the form's width one per row left most of each row empty and pushed the
+    // rest below the fold. The two that stay full width earn it: the spool
+    // catalogue picker carries a long product name beside its own number
+    // input, and the note is a textarea.
+    <div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-4">
       {/* Empty Spool Weight — hidden in Spoolman mode (managed per filament type in Spoolman) */}
-      {spoolmanMode ? (
-        <p className="text-xs text-bambu-gray px-1">{t('inventory.spoolWeightManagedBySpoolman')}</p>
-      ) : (
-        <SpoolWeightPicker
-          catalog={spoolCatalog}
-          value={formData.core_weight}
-          onChange={(weight) => updateField('core_weight', weight)}
-          catalogId={formData.core_weight_catalog_id}
-          onCatalogIdChange={(id) => updateField('core_weight_catalog_id', id)}
-        />
-      )}
+      <div className="sm:col-span-2">
+        {spoolmanMode ? (
+          <p className="text-xs text-bambu-gray px-1">{t('inventory.spoolWeightManagedBySpoolman')}</p>
+        ) : (
+          <SpoolWeightPicker
+            catalog={spoolCatalog}
+            value={formData.core_weight}
+            onChange={(weight) => updateField('core_weight', weight)}
+            catalogId={formData.core_weight_catalog_id}
+            onCatalogIdChange={(id) => updateField('core_weight_catalog_id', id)}
+          />
+        )}
+      </div>
 
       {/* Current Weight (remaining filament) */}
       <div>
@@ -372,17 +379,6 @@ export function AdditionalSection({
         </p>
       </div>
 
-      {/* Note */}
-      <div>
-        <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.note')}</label>
-        <textarea
-          className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green resize-none min-h-[80px]"
-          placeholder={t('inventory.notePlaceholder')}
-          value={formData.note}
-          onChange={(e) => updateField('note', e.target.value)}
-        />
-      </div>
-
       {/* Storage Location */}
       <div>
         <label className="block text-sm font-medium text-bambu-gray mb-1" htmlFor="spool-storage-location">
@@ -441,6 +437,17 @@ export function AdditionalSection({
           </div>
         )}
       </div>
+
+      {/* Note */}
+      <div className="sm:col-span-2">
+        <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.note')}</label>
+        <textarea
+          className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green resize-none min-h-[80px]"
+          placeholder={t('inventory.notePlaceholder')}
+          value={formData.note}
+          onChange={(e) => updateField('note', e.target.value)}
+        />
+      </div>
     </div>
   );
 }

+ 10 - 2
frontend/src/components/spool-form/FilamentSection.tsx

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
 import type { FilamentSectionProps, FilamentOption } from './types';
 import { KNOWN_VARIANTS } from './constants';
 import { parsePresetName } from './utils';
+import { PresetSourceBadge } from './PresetPicker';
 
 // The identity fields a slicer preset can auto-fill.
 type PresetFilledField = 'material' | 'brand' | 'subtype';
@@ -268,14 +269,21 @@ export function FilamentSection({
                     <button
                       key={`${option.code}::${option.name}`}
                       type="button"
-                      className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary truncate ${
+                      className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex items-center gap-2 ${
                         selectedPresetOption?.code === option.code
                           ? 'bg-bambu-green/10 text-bambu-green'
                           : 'text-white'
                       }`}
                       onClick={() => handlePresetSelect(option)}
                     >
-                      {option.displayName}
+                      {/* Same origin badge as the Printers tab and the
+                          Configure AMS Slot modal: the same filament exists as
+                          a cloud preset, an imported one and a built-in, and
+                          which is picked decides what reaches the printer. */}
+                      <span className="flex-1 min-w-0 truncate" title={option.displayName}>
+                        {option.displayName}
+                      </span>
+                      <PresetSourceBadge source={option.source} />
                     </button>
                   ))
                 )}

+ 188 - 0
frontend/src/components/spool-form/PresetPicker.tsx

@@ -0,0 +1,188 @@
+import { useEffect, useRef, useState } from 'react';
+import { ChevronDown, Search, X } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import type { FilamentOption, FilamentOptionSource } from './types';
+
+/**
+ * Pick one filament preset, showing where each preset came from.
+ *
+ * A native `<select>` cannot carry the origin badge, and the origin is not
+ * decoration: the same filament exists as a Bambu Cloud preset, an Orca Cloud
+ * profile, a locally imported one and a built-in, and which one you pick
+ * decides what actually reaches the printer. The Configure AMS Slot modal has
+ * shown these badges for that reason since #1623; this is the same wording and
+ * the same colours, so the two screens read alike.
+ *
+ * Deliberately not a shared "Select" component: it is a listbox of buttons
+ * because each row is a name plus a badge, and it filters as you type because
+ * a cloud account with a few hundred presets is ordinary.
+ */
+
+const BADGE_CLASSES: Record<FilamentOptionSource, string> = {
+  local: 'bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400',
+  orca_cloud: 'bg-purple-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-400',
+  cloud: 'bg-bambu-blue/20 text-bambu-blue',
+  builtin: 'bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400',
+};
+
+export function PresetSourceBadge({ source }: { source: FilamentOptionSource }) {
+  const { t } = useTranslation();
+  // The same four labels the Configure AMS Slot modal uses, by the same keys --
+  // one wording per source across the app rather than a second set to keep in
+  // step.
+  const label = {
+    local: t('profiles.localProfiles.badge'),
+    orca_cloud: t('configureAmsSlot.orcaCloud'),
+    cloud: t('configureAmsSlot.bambuCloud'),
+    builtin: t('configureAmsSlot.builtin'),
+  }[source];
+
+  return (
+    <span className={`text-[10px] px-1.5 py-0.5 rounded whitespace-nowrap ${BADGE_CLASSES[source]}`}>
+      {label}
+    </span>
+  );
+}
+
+interface PresetPickerProps {
+  /** Currently chosen preset code, or '' for "inherit". */
+  value: string;
+  options: FilamentOption[];
+  /** What the empty choice reads as, e.g. "Use the spool's preset". */
+  inheritLabel: string;
+  onChange: (option: FilamentOption | null) => void;
+  disabled?: boolean;
+  ariaLabel: string;
+}
+
+export function PresetPicker({
+  value,
+  options,
+  inheritLabel,
+  onChange,
+  disabled = false,
+  ariaLabel,
+}: PresetPickerProps) {
+  const { t } = useTranslation();
+  const [open, setOpen] = useState(false);
+  const [query, setQuery] = useState('');
+  const rootRef = useRef<HTMLDivElement>(null);
+
+  const selected = options.find(o => o.code === value);
+
+  useEffect(() => {
+    if (!open) return;
+    const onPointerDown = (e: MouseEvent) => {
+      if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
+    };
+    // Escape closes this control WITHOUT closing the modal around it, which is
+    // what a bare keydown listener on the document would otherwise let happen.
+    const onKeyDown = (e: KeyboardEvent) => {
+      if (e.key !== 'Escape') return;
+      e.stopPropagation();
+      setOpen(false);
+    };
+    document.addEventListener('mousedown', onPointerDown);
+    document.addEventListener('keydown', onKeyDown, true);
+    return () => {
+      document.removeEventListener('mousedown', onPointerDown);
+      document.removeEventListener('keydown', onKeyDown, true);
+    };
+  }, [open]);
+
+  const needle = query.trim().toLowerCase();
+  const shown = needle
+    ? options.filter(o => o.displayName.toLowerCase().includes(needle))
+    : options;
+
+  const choose = (option: FilamentOption | null) => {
+    onChange(option);
+    setOpen(false);
+    setQuery('');
+  };
+
+  return (
+    <div className="relative" ref={rootRef}>
+      <button
+        type="button"
+        aria-label={ariaLabel}
+        aria-haspopup="listbox"
+        aria-expanded={open}
+        disabled={disabled}
+        onClick={() => setOpen(o => !o)}
+        className="w-full flex items-center gap-2 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-sm text-left focus:outline-none focus:border-bambu-green disabled:opacity-50"
+      >
+        <span className={`flex-1 min-w-0 truncate ${selected ? 'text-white' : 'text-bambu-gray italic'}`}>
+          {selected ? selected.displayName : inheritLabel}
+        </span>
+        {selected && <PresetSourceBadge source={selected.source} />}
+        <ChevronDown className="w-3.5 h-3.5 text-bambu-gray shrink-0" />
+      </button>
+
+      {open && (
+        <div className="absolute z-50 mt-1 w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl">
+          <div className="p-2 border-b border-bambu-dark-tertiary">
+            <div className="relative">
+              <Search className="w-3.5 h-3.5 text-bambu-gray absolute left-2 top-1/2 -translate-y-1/2" />
+              <input
+                autoFocus
+                type="text"
+                value={query}
+                onChange={e => setQuery(e.target.value)}
+                placeholder={t('inventory.searchPresets')}
+                className="w-full pl-7 pr-7 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded-md text-sm text-white placeholder:text-bambu-gray/60 focus:outline-none focus:border-bambu-green"
+              />
+              {query && (
+                <button
+                  type="button"
+                  aria-label={t('common.clear')}
+                  onClick={() => setQuery('')}
+                  className="absolute right-2 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white"
+                >
+                  <X className="w-3.5 h-3.5" />
+                </button>
+              )}
+            </div>
+          </div>
+
+          <div className="max-h-56 overflow-y-auto p-1" role="listbox" aria-label={ariaLabel}>
+            <button
+              type="button"
+              role="option"
+              aria-selected={!selected}
+              onClick={() => choose(null)}
+              className={`w-full text-left px-2 py-1.5 rounded-md text-sm italic ${
+                selected ? 'text-bambu-gray hover:bg-bambu-dark' : 'bg-bambu-green/15 text-bambu-green'
+              }`}
+            >
+              {inheritLabel}
+            </button>
+            {shown.length === 0 ? (
+              <p className="px-2 py-2 text-sm text-bambu-gray">{t('inventory.noResults')}</p>
+            ) : (
+              shown.map(option => (
+                <button
+                  key={option.code}
+                  type="button"
+                  role="option"
+                  aria-selected={option.code === value}
+                  onClick={() => choose(option)}
+                  className={`w-full flex items-center gap-2 text-left px-2 py-1.5 rounded-md text-sm ${
+                    option.code === value
+                      ? 'bg-bambu-green/15 text-bambu-green'
+                      : 'text-white hover:bg-bambu-dark'
+                  }`}
+                >
+                  <span className="flex-1 min-w-0 truncate" title={option.displayName}>
+                    {option.displayName}
+                  </span>
+                  <PresetSourceBadge source={option.source} />
+                </button>
+              ))
+            )}
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}

+ 650 - 0
frontend/src/components/spool-form/PrinterProfilesSection.tsx

@@ -0,0 +1,650 @@
+import { Fragment, useMemo } from 'react';
+import { Check, Loader2, Printer as PrinterIcon, Sparkles } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import type {
+  CalibrationProfile,
+  FilamentOption,
+  PrinterProfilesSectionProps,
+  PrinterWithCalibrations,
+} from './types';
+import { hotendKey, isMatchingCalibration, presetKey } from './utils';
+import { STANDARD_NOZZLE_DIAMETERS } from './constants';
+import { PresetPicker } from './PresetPicker';
+import { extractPresetModel, matchesPrinterModelSuffix } from '../../utils/slicerPrinterMatch';
+import { flowLabel, normaliseFlow } from '../../utils/nozzleFlow';
+import type { NozzleFlow } from '../../utils/nozzleFlow';
+
+/**
+ * The spool form's Printers tab: which filament preset this spool uses on each
+ * printer MODEL, and which K profile it uses on each individual hotend.
+ *
+ * The two halves are keyed differently on purpose. A slicer preset is a
+ * property of the model -- "@BBL X1C" is the same preset on every X1C the user
+ * owns -- so asking per machine would make them pick the identical value once
+ * per printer. A K value is measured on one individual hotend, so it stays per
+ * printer, per extruder, per nozzle diameter, which is what both K tables have
+ * always been keyed on.
+ *
+ * Layout is a model list plus a detail pane rather than a stack of cards: the
+ * list is fixed height whatever the fleet size, and the detail pane is bounded
+ * by the largest single model instead of by the total number of printers.
+ *
+ * Nothing here reads `status.nozzles[]` positionally. Which array index belongs
+ * to which extruder is genuinely unsettled in the backend (the H2/X2 and legacy
+ * MQTT parsers disagree), so every nozzle fact on this screen comes from data
+ * that names its own extruder: a calibration profile carries both its
+ * `extruder_id` and its `nozzle_diameter`, and the per-model diameter list is a
+ * deduplicated SET, which no ordering can get wrong.
+ */
+
+interface ModelGroup {
+  /**
+   * Identifies the row. Prefixed so the two kinds cannot collide: `m:` for a
+   * real model, `p:` for a printer that has not reported one. Distinct from
+   * `model` because a model-less printer has no model to be keyed by, and two
+   * of them would otherwise be the same row.
+   */
+  id: string;
+  /** Empty when the printer has not reported a model. */
+  model: string;
+  printers: PrinterWithCalibrations[];
+  /** Distinct nozzle diameters across this model's machines. Order-independent. */
+  diameters: string[];
+}
+
+/**
+ * The flow type fitted to one hotend, or null when the printer does not say.
+ *
+ * Same array and the same indexing as the diameter. Legacy printers put the
+ * nozzle MATERIAL in this field ("hardened_steel"), which normaliseFlow reads
+ * as "unknown" -- correct, since those machines never report a flow.
+ */
+function fittedFlow(entry: PrinterWithCalibrations, extruder: number): NozzleFlow | null {
+  const nozzles = entry.nozzles ?? [];
+  const isDual = (entry.printer.nozzle_count ?? 1) > 1;
+  const index = isDual && extruder > 0 ? extruder : 0;
+  return normaliseFlow(nozzles[index]?.nozzle_type) ?? normaliseFlow(nozzles[0]?.nozzle_type);
+}
+
+function distinctDiameters(entry: PrinterWithCalibrations): string[] {
+  const seen = new Set<string>();
+  for (const nozzle of entry.nozzles ?? []) {
+    const raw = (nozzle?.nozzle_diameter ?? '').trim();
+    if (raw && parseFloat(raw) > 0) seen.add(raw);
+  }
+  for (const cal of entry.calibrations) {
+    const raw = (cal.nozzle_diameter ?? '').trim();
+    if (raw && parseFloat(raw) > 0) seen.add(raw);
+  }
+  return Array.from(seen).sort((a, b) => parseFloat(a) - parseFloat(b));
+}
+
+/**
+ * The hotend columns for one printer, in the order they sit on the machine.
+ *
+ * Extruder 0 is the RIGHT hotend and 1 is the left, so a left-to-right table
+ * reads [1, 0]. A single-nozzle machine has one unnamed column -- there is no
+ * side to name.
+ */
+function columnsOf(
+  entry: PrinterWithCalibrations,
+  labels: { left: string; right: string; single: string },
+): Array<{ extruder: number; label: string }> {
+  if ((entry.printer.nozzle_count ?? 1) > 1) {
+    return [
+      { extruder: 1, label: labels.left },
+      { extruder: 0, label: labels.right },
+    ];
+  }
+  return [{ extruder: 0, label: labels.single }];
+}
+
+export function PrinterProfilesSection({
+  formData,
+  printersWithCalibrations,
+  filamentOptions,
+  modelPresets,
+  setModelPresets,
+  selectedProfiles,
+  setSelectedProfiles,
+  selectedGroupId,
+  setSelectedGroupId,
+  printerModels,
+  isLoading = false,
+}: PrinterProfilesSectionProps) {
+  const { t } = useTranslation();
+
+  // Group the fleet by model. A printer whose model the backend has not
+  // reported is grouped under its own name rather than dropped -- it still has
+  // K profiles worth setting, and the preset row is disabled for it below.
+  const groups = useMemo<ModelGroup[]>(() => {
+    const byModel = new Map<string, PrinterWithCalibrations[]>();
+    const modelless: PrinterWithCalibrations[] = [];
+    for (const entry of printersWithCalibrations) {
+      const model = (entry.printer.model || '').trim();
+      if (!model) {
+        modelless.push(entry);
+        continue;
+      }
+      const list = byModel.get(model);
+      if (list) list.push(entry);
+      else byModel.set(model, [entry]);
+    }
+
+    const grouped = Array.from(byModel.entries())
+      .map(([model, printers]) => ({
+        id: `m:${model}`,
+        model,
+        printers,
+        // Every standard size, plus anything unusual this model reports as
+        // fitted. Not just the fitted ones: a spool is configured once and
+        // nozzles get swapped, so the user has to be able to set the preset
+        // for a size they are about to change to.
+        diameters: Array.from(
+          new Set([...STANDARD_NOZZLE_DIAMETERS, ...printers.flatMap(distinctDiameters)]),
+        ).sort((a, b) => parseFloat(a) - parseFloat(b)),
+      }))
+      .sort((a, b) => a.model.localeCompare(b.model));
+
+    // A printer that has not reported its model gets a row of its own, last:
+    // it has K profiles worth setting but cannot share a preset with anything,
+    // and it must not be folded in with other model-less printers.
+    return [
+      ...grouped,
+      ...modelless.map(entry => ({
+        id: `p:${entry.printer.id}`,
+        model: '',
+        printers: [entry],
+        diameters: Array.from(
+          new Set([...STANDARD_NOZZLE_DIAMETERS, ...distinctDiameters(entry)]),
+        ).sort((a, b) => parseFloat(a) - parseFloat(b)),
+      })),
+    ];
+  }, [printersWithCalibrations]);
+
+  const active = groups.find(g => g.id === selectedGroupId) ?? groups[0];
+
+  /**
+   * The presets worth offering for one model.
+   *
+   * A preset name carries the model it belongs to ("@BBL H2C", "@Bambu Lab X1
+   * Carbon", or just "X1C ..." at the front), and offering an X1C preset for an
+   * H2C is offering something that machine has no profile for -- which is the
+   * bug this whole tab exists to fix. Uses the same matcher the Configure AMS
+   * Slot modal filters with, so the two lists agree.
+   *
+   * Two things are deliberately kept: a preset whose model cannot be read at
+   * all (many user-authored and Orca presets name no model), because hiding
+   * what we cannot classify would hide most third-party profiles; and whatever
+   * is currently selected, so an override already saved never silently
+   * disappears from the control that shows it.
+   */
+  const optionsForModel = useMemo(() => {
+    const cache = new Map<string, FilamentOption[]>();
+    return (model: string, selected: string | undefined): FilamentOption[] => {
+      if (!model) return filamentOptions;
+      let list = cache.get(model);
+      if (!list) {
+        list = filamentOptions.filter(option => {
+          const presetModel = extractPresetModel(option.name, printerModels ?? {});
+          return !presetModel || matchesPrinterModelSuffix(presetModel, model);
+        });
+        cache.set(model, list);
+      }
+      if (selected && !list.some(o => o.code === selected)) {
+        const kept = filamentOptions.find(o => o.code === selected);
+        if (kept) return [kept, ...list];
+      }
+      return list;
+    };
+  }, [filamentOptions, printerModels]);
+
+  // "Bambu PLA Matte", from whichever of the three fields are filled in. Blank
+  // parts are skipped rather than padded with "Any brand", which reads as a
+  // filter setting rather than as what the spool is.
+  const identity = [formData.brand, formData.material, formData.subtype]
+    .map(part => part.trim())
+    .filter(Boolean)
+    .join(' ');
+
+  // The spool's own colour. rgba is RRGGBBAA; the alpha is dropped because a
+  // translucent swatch would show the panel behind it rather than the filament.
+  const swatch = /^[0-9A-Fa-f]{6,8}$/.test(formData.rgba)
+    ? `#${formData.rgba.slice(0, 6)}`
+    : 'var(--bambu-gray, #808080)';
+
+  const columns = (entry: PrinterWithCalibrations) =>
+    columnsOf(entry, {
+      left: t('inventory.leftNozzle'),
+      right: t('inventory.rightNozzle'),
+      single: t('inventory.nozzle'),
+    });
+
+  const matchingFor = (entry: PrinterWithCalibrations) =>
+    entry.printer.connected
+      ? entry.calibrations.filter(cal => isMatchingCalibration(cal, formData))
+      : [];
+
+  /**
+   * Grid cells that could hold a K profile but do not -- the left rail's
+   * "unset" badge. Only cells with something to choose are counted: a size the
+   * printer has no calibration for is not an unfinished decision.
+   */
+  const unsetCount = (group: ModelGroup) => {
+    let unset = 0;
+    for (const entry of group.printers) {
+      const matching = matchingFor(entry);
+      for (const column of columns(entry)) {
+        for (const diameter of group.diameters) {
+          const hasCandidate = matching.some(
+            cal =>
+              (cal.extruder_id ?? 0) === column.extruder
+              && ((cal.nozzle_diameter ?? '').trim() || '0.4') === diameter,
+          );
+          if (!hasCandidate) continue;
+          if (!selectedProfiles.get(hotendKey(entry.printer.id, column.extruder, diameter))) unset++;
+        }
+      }
+    }
+    return unset;
+  };
+
+  const setPreset = (model: string, diameter: string, option: FilamentOption | null) => {
+    setModelPresets(prev => {
+      const next = new Map(prev);
+      const key = presetKey(model, diameter);
+      // Removing the entry is what "inherited" means -- the backend cascade
+      // falls through to the spool's own preset when no row exists. Storing a
+      // row that repeats the spool's value would freeze it instead: later
+      // edits to the spool preset would stop reaching this model.
+      if (!option) next.delete(key);
+      else next.set(key, { code: option.code, name: option.name });
+      return next;
+    });
+  };
+
+  const chooseProfile = (
+    printerId: number,
+    extruder: number,
+    diameter: string,
+    cal: CalibrationProfile | null,
+  ) => {
+    setSelectedProfiles(prev => {
+      const next = new Map(prev);
+      const key = hotendKey(printerId, extruder, diameter);
+      if (!cal) next.delete(key);
+      else next.set(key, cal);
+      return next;
+    });
+  };
+
+  /**
+   * Fill each model's preset with the variant of the spool's own preset that
+   * names that model. Preset names are mechanical ("Bambu PLA Basic @BBL X1C"),
+   * so the match is a name comparison, not a guess about filament identity: a
+   * model with no such variant is left inherited rather than given something
+   * approximate.
+   */
+  const autoMatch = () => {
+    const base = filamentOptions.find(o => o.code === formData.slicer_filament);
+    if (!base) return;
+    const stem = base.name.split('@')[0].trim().toLowerCase();
+    if (!stem) return;
+
+    setModelPresets(prev => {
+      const next = new Map(prev);
+      for (const group of groups) {
+        if (!group.model) continue;
+        // Only presets that name this model. An unclassifiable one stays in the
+        // list to be picked by hand but is never assigned for the user.
+        const candidates = optionsForModel(group.model, undefined).filter(
+          option =>
+            option.name.toLowerCase().startsWith(stem)
+            && extractPresetModel(option.name, printerModels ?? {}) !== null,
+        );
+        if (candidates.length === 0) continue;
+
+        for (const diameter of group.diameters) {
+          // Bambu names the size in the preset ("@BBL X1C 0.4 nozzle"), so
+          // prefer the variant for this size and fall back to one that names
+          // the model without a size. A size with neither is left inherited --
+          // an approximate preset is worse than the spool's own.
+          const sized = candidates.find(option =>
+            new RegExp(`\\b${diameter.replace('.', '\\.')}\\s*nozzle\\b`, 'i').test(option.name),
+          );
+          const unsized = candidates.find(option => !/\b[\d.]+\s*nozzle\b/i.test(option.name));
+          const match = sized ?? unsized;
+          if (match) next.set(presetKey(group.model, diameter), { code: match.code, name: match.name });
+        }
+      }
+      return next;
+    });
+  };
+
+  if (printersWithCalibrations.length === 0) {
+    return (
+      <div className="p-6 bg-bambu-dark rounded-lg text-center">
+        {/* "No printers configured" is a claim about the user's setup and must
+            not be made while the printers are still being asked -- reading each
+            one's calibration table is several MQTT round trips. */}
+        <p className="text-bambu-gray flex items-center justify-center gap-2">
+          {isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
+          {isLoading ? t('common.loading') : t('inventory.noPrintersConfigured')}
+        </p>
+      </div>
+    );
+  }
+
+  const renderPresetRow = (model: string, diameter: string, inheritLabel: string) => {
+    const key = presetKey(model, diameter);
+    const chosen = modelPresets.get(key);
+    const options = optionsForModel(model, chosen?.code);
+    return (
+      <div className="flex items-center gap-2">
+        <div className="flex-1 min-w-0">
+          <PresetPicker
+            ariaLabel={`${model} ${diameter}mm ${t('inventory.filamentPreset')}`}
+            value={chosen?.code ?? ''}
+            options={options}
+            inheritLabel={inheritLabel}
+            disabled={!model}
+            onChange={option => setPreset(model, diameter, option)}
+          />
+        </div>
+        <span
+          className={`text-[10px] font-semibold uppercase tracking-wide px-2 py-1 rounded-full shrink-0 ${
+            chosen ? 'bg-bambu-green/20 text-bambu-green' : 'bg-bambu-dark-tertiary text-bambu-gray'
+          }`}
+        >
+          {chosen ? t('inventory.presetOverride') : t('inventory.presetInherited')}
+        </span>
+      </div>
+    );
+  };
+
+  return (
+    <div className="space-y-3">
+      {/* Which spool is being configured. Worth a line of its own here: this
+          tab is the one place you read printer names rather than filament, and
+          the K-profile lists below are filtered by exactly these fields --
+          brand, material and subtype -- so an empty list is explained by what
+          this line says. */}
+      {(identity || formData.color_name) && (
+        <div className="flex items-center gap-2.5 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg">
+          <span
+            className="w-5 h-5 rounded-full border border-white/15 shrink-0"
+            style={{ background: swatch }}
+            aria-hidden="true"
+          />
+          {identity && <span className="text-sm text-white truncate">{identity}</span>}
+          {formData.color_name && (
+            <span className="text-sm text-bambu-gray truncate">{formData.color_name}</span>
+          )}
+        </div>
+      )}
+
+      <div className="flex flex-col md:flex-row gap-4">
+      {/* Model list. Sticky rather than its own scroll region: a second
+          scrollbar inside the modal's own scrolling body means the user has to
+          find which one moves the thing they are looking at. */}
+      <div
+        role="tablist"
+        aria-label={t('inventory.printersTab')}
+        aria-orientation="vertical"
+        className="md:w-52 md:shrink-0 md:self-start md:sticky md:top-0 flex md:flex-col gap-1.5 overflow-x-auto md:overflow-x-visible"
+      >
+        {groups.map(group => {
+          const isActive = group === active;
+          const unset = unsetCount(group);
+          return (
+            <button
+              key={group.id}
+              type="button"
+              role="tab"
+              onClick={() => setSelectedGroupId(group.id)}
+              aria-selected={isActive}
+              className={`flex items-center gap-2 px-3 py-2 rounded-lg border text-left transition-colors shrink-0 md:shrink ${
+                isActive
+                  ? 'bg-bambu-green/10 border-bambu-green/40 text-white'
+                  : 'bg-transparent border-transparent text-bambu-gray hover:bg-bambu-dark hover:text-white'
+              }`}
+            >
+              <div className="min-w-0 flex-1">
+                <div className="text-sm font-semibold truncate">
+                  {group.model || t('inventory.unknownModel')}
+                </div>
+                <div className="text-[11px] text-bambu-gray">
+                  {group.printers.length === 1
+                    ? t('inventory.onePrinter')
+                    : t('inventory.nPrinters', { n: group.printers.length })}
+                </div>
+              </div>
+              {unset > 0 ? (
+                <span className="text-[10px] font-mono px-1.5 py-0.5 rounded-full bg-bambu-dark-tertiary text-bambu-gray shrink-0">
+                  {unset}
+                </span>
+              ) : (
+                <Check className="w-3.5 h-3.5 text-bambu-green shrink-0" />
+              )}
+            </button>
+          );
+        })}
+      </div>
+
+      {/* Detail */}
+      <div className="flex-1 min-w-0">
+        {active && (
+          <div className="space-y-4">
+            <div className="flex items-start justify-between gap-3">
+              <div className="min-w-0">
+                <h4 className="text-base font-semibold text-white truncate">
+                  {active.model || t('inventory.unknownModel')}
+                </h4>
+                <p className="text-xs text-bambu-gray">
+                  {active.printers.map(p => p.printer.name).join(', ')}
+                </p>
+              </div>
+              {formData.slicer_filament && (
+                <button
+                  type="button"
+                  onClick={autoMatch}
+                  title={t('inventory.autoMatchPresetsHint')}
+                  className="flex items-center gap-1.5 px-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white hover:border-bambu-green transition-colors shrink-0"
+                >
+                  <Sparkles className="w-3.5 h-3.5" />
+                  {t('inventory.autoMatchPresets')}
+                </button>
+              )}
+            </div>
+
+            {/* Filament preset — model scoped */}
+            <div className="space-y-2">
+              <p className="text-xs font-semibold text-bambu-gray uppercase tracking-wide">
+                {t('inventory.filamentPreset')}
+              </p>
+              {!active.model ? (
+                <p className="text-sm text-bambu-gray italic">{t('inventory.presetNeedsModel')}</p>
+              ) : (
+                /* One row per nozzle size, and no model-wide row above them:
+                   the preset is written to an AMS slot, a slot feeds exactly
+                   one nozzle, and Bambu names its presets per size anyway
+                   ("@BBL X1C 0.4 nozzle"). A size left alone falls straight
+                   back to the spool's own preset. */
+                <div className="space-y-2">
+                  {active.diameters.map(diameter => (
+                    <div key={diameter} className="flex items-center gap-3">
+                      <span className="text-xs font-mono text-bambu-gray w-14 shrink-0">
+                        {diameter}mm
+                      </span>
+                      <div className="flex-1 min-w-0">
+                        {renderPresetRow(
+                          active.model,
+                          diameter,
+                          t('inventory.presetUseSpoolDefault'),
+                        )}
+                      </div>
+                    </div>
+                  ))}
+                </div>
+              )}
+            </div>
+
+            {/* K profiles — machine scoped */}
+            <div className="space-y-2">
+              <p className="text-xs font-semibold text-bambu-gray uppercase tracking-wide">
+                {t('inventory.kProfilesPerPrinter')}
+              </p>
+              {!formData.material ? (
+                <p className="text-sm text-bambu-gray italic">{t('inventory.selectMaterialFirst')}</p>
+              ) : (
+                active.printers.map(entry => {
+                  const matching = matchingFor(entry);
+                  return (
+                    <div
+                      key={entry.printer.id}
+                      className="p-3 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg"
+                    >
+                      <div className="flex items-center gap-2 mb-1">
+                        <PrinterIcon className="w-3.5 h-3.5 text-bambu-gray shrink-0" />
+                        <span className="text-sm font-semibold text-white truncate">
+                          {entry.printer.name}
+                        </span>
+                        <span
+                          className={`text-[10px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full shrink-0 ${
+                            entry.printer.connected
+                              ? 'bg-green-500/20 text-green-500'
+                              : 'bg-bambu-dark-tertiary text-bambu-gray'
+                          }`}
+                        >
+                          {entry.printer.connected
+                            ? t('inventory.connected')
+                            : t('inventory.offline')}
+                        </span>
+                      </div>
+
+                      {!entry.printer.connected ? (
+                        <p className="text-sm text-bambu-gray italic py-1">
+                          {t('inventory.printerOffline')}
+                        </p>
+                      ) : matching.length === 0 ? (
+                        <p className="text-sm text-bambu-gray italic py-1">
+                          {t('inventory.noKProfilesMatch')}
+                        </p>
+                      ) : (
+                        /* A grid rather than a list of rows: nozzle size down
+                           the side, hotend across the top. A dual-nozzle
+                           machine has up to eight cells, and stacked rows made
+                           that a scroll where a table is a glance. Columns run
+                           left-then-right to match the machine, which is the
+                           reverse of the extruder ids behind them (extruder 0
+                           is the RIGHT hotend). */
+                        <div
+                          className="grid gap-x-3 gap-y-1.5 items-center"
+                          style={{
+                            gridTemplateColumns: `3.5rem repeat(${columns(entry).length}, minmax(0, 1fr))`,
+                          }}
+                        >
+                          <span />
+                          {columns(entry).map(column => (
+                            <span
+                              key={column.extruder}
+                              className="text-[11px] font-semibold uppercase tracking-wide text-bambu-gray"
+                            >
+                              {column.label}
+                            </span>
+                          ))}
+
+                          {active.diameters.map(diameter => (
+                            <Fragment key={diameter}>
+                              <span className="text-xs font-mono text-bambu-gray">{diameter}mm</span>
+                              {columns(entry).map(column => {
+                                const candidates = matching.filter(
+                                  cal =>
+                                    (cal.extruder_id ?? 0) === column.extruder
+                                    && ((cal.nozzle_diameter ?? '').trim() || '0.4') === diameter,
+                                );
+                                const key = hotendKey(entry.printer.id, column.extruder, diameter);
+                                const chosen = selectedProfiles.get(key);
+                                if (candidates.length === 0) {
+                                  return (
+                                    // The printer has no calibration for this
+                                    // size on this hotend. Shown rather than
+                                    // omitted so the size is visibly accounted
+                                    // for instead of looking forgotten.
+                                    <span
+                                      key={key}
+                                      className="text-xs text-bambu-gray/50 px-2 py-1.5"
+                                      title={t('inventory.noKProfilesMatch')}
+                                    >
+                                      &mdash;
+                                    </span>
+                                  );
+                                }
+                                // A stored profile whose flow disagrees with
+                                // the nozzle now fitted is not applied at
+                                // assign time -- a K value measured on a
+                                // high-flow nozzle is not a fact about a
+                                // standard one. Say so here rather than let it
+                                // look configured and quietly do nothing.
+                                const fitted = fittedFlow(entry, column.extruder);
+                                const chosenFlow = normaliseFlow(chosen?.nozzle_id);
+                                const flowMismatch = !!(fitted && chosenFlow && fitted !== chosenFlow);
+                                return (
+                                  <select
+                                    key={key}
+                                    title={
+                                      flowMismatch
+                                        ? t('inventory.kProfileFlowMismatch', {
+                                            profile: flowLabel(chosenFlow),
+                                            fitted: flowLabel(fitted),
+                                          })
+                                        : undefined
+                                    }
+                                    aria-label={`${entry.printer.name} ${column.label} ${diameter}mm`}
+                                    value={chosen ? String(chosen.cali_idx) : ''}
+                                    onChange={e => {
+                                      const cal =
+                                        candidates.find(c => String(c.cali_idx) === e.target.value)
+                                        ?? null;
+                                      chooseProfile(entry.printer.id, column.extruder, diameter, cal);
+                                    }}
+                                    className={`min-w-0 px-2 py-1.5 bg-bambu-dark-secondary border rounded-lg text-sm text-white focus:outline-none focus:border-bambu-green ${
+                                      flowMismatch ? 'border-amber-500/60' : 'border-bambu-dark-tertiary'
+                                    }`}
+                                  >
+                                    <option value="">{t('inventory.kProfileNotSet')}</option>
+                                    {candidates.map(cal => {
+                                      // The flow the profile was measured on.
+                                      // Shown because the same filament reads a
+                                      // different K through a high-flow nozzle,
+                                      // and a printer can hold both -- this H2D
+                                      // has 102 high-flow entries and 6
+                                      // standard. Omitted where the printer
+                                      // declares none (an X1C declares none at
+                                      // all), since there is nothing to say.
+                                      const label = flowLabel(normaliseFlow(cal.nozzle_id));
+                                      return (
+                                        <option key={cal.cali_idx} value={cal.cali_idx}>
+                                          {`${label ? `[${label}] ` : ''}${cal.name || cal.filament_id}  K=${cal.k_value.toFixed(3)}`}
+                                        </option>
+                                      );
+                                    })}
+                                  </select>
+                                );
+                              })}
+                            </Fragment>
+                          ))}
+                        </div>
+                      )}
+                    </div>
+                  );
+                })
+              )}
+            </div>
+          </div>
+        )}
+      </div>
+      </div>
+    </div>
+  );
+}

+ 10 - 0
frontend/src/components/spool-form/constants.ts

@@ -107,3 +107,13 @@ export const ALL_COLORS: ColorPreset[] = [...QUICK_COLORS, ...EXTENDED_COLORS];
 // Local storage keys
 export const RECENT_COLORS_KEY = 'bambuddy-recent-colors';
 export const MAX_RECENT_COLORS = 8;
+
+/**
+ * The nozzle sizes Bambu sells, smallest first.
+ *
+ * The spool form offers a filament preset and a K profile for every one of
+ * them, not only the size currently screwed into the machine: a spool is
+ * configured once and nozzles get swapped, and the calibration table on the
+ * printer keeps entries per diameter regardless of what is fitted right now.
+ */
+export const STANDARD_NOZZLE_DIAMETERS = ['0.2', '0.4', '0.6', '0.8'];

+ 52 - 0
frontend/src/components/spool-form/types.ts

@@ -72,6 +72,20 @@ export const defaultFormData: SpoolFormData = {
 export interface PrinterWithCalibrations {
   printer: Printer & { connected?: boolean };
   calibrations: CalibrationProfile[];
+  // Nozzle hardware as the printer reports it, kept so the Printers tab can
+  // list a model's installed diameters. Read as a SET of diameters only --
+  // never indexed by extruder, because which array position belongs to which
+  // extruder is unsettled between the two MQTT parsers. Optional: callers that
+  // predate the Printers tab (SpoolBuddy's write-tag page) do not supply it.
+  nozzles?: { nozzle_diameter?: string; nozzle_type?: string }[];
+}
+
+// One spool's chosen preset for a printer model, as the Printers tab holds it
+// before it is saved. `name` is kept alongside the code so the row can be
+// rendered without re-searching the preset list.
+export interface PresetChoice {
+  code: string;
+  name: string;
 }
 
 // Calibration profile from printer status
@@ -84,8 +98,45 @@ export interface CalibrationProfile {
   n_coef: number;
   extruder_id?: number | null;
   nozzle_diameter?: string;
+  // The nozzle this profile was filed under, e.g. "HH00-0.4" (high flow) or
+  // "HS00-0.4" (standard). Empty on printers that declare none -- an X1C sends
+  // none at all -- which means "unknown", never Standard. See utils/nozzleFlow.
+  nozzle_id?: string;
+}
+
+// Printers tab props. `modelPresets` is keyed by `presetKey(model, diameter)`
+// and holds only the models the user has overridden -- an absent entry is
+// "inherit the spool's own preset", which is exactly what the backend cascade
+// does with a missing row. `selectedProfiles` is keyed by hotend
+// (`printerId:extruder:diameter`), one K profile per hotend by construction.
+export interface PrinterProfilesSectionProps {
+  formData: SpoolFormData;
+  printersWithCalibrations: PrinterWithCalibrations[];
+  filamentOptions: FilamentOption[];
+  modelPresets: Map<string, PresetChoice>;
+  setModelPresets: React.Dispatch<React.SetStateAction<Map<string, PresetChoice>>>;
+  selectedProfiles: Map<string, CalibrationProfile>;
+  setSelectedProfiles: React.Dispatch<React.SetStateAction<Map<string, CalibrationProfile>>>;
+  // Which row of the model list is open. A group id (see ModelGroup), not a
+  // model name: a printer that has not reported its model still gets a row.
+  selectedGroupId: string;
+  setSelectedGroupId: (groupId: string) => void;
+  // Backend printer-model registry ("Bambu Lab X1 Carbon" -> "X1C"), used to
+  // read the model out of a preset name so each model is offered only the
+  // presets that belong to it. Undefined until the query resolves, which just
+  // means no filtering yet rather than an empty list.
+  printerModels?: Record<string, string>;
+  // True while the printers are still being asked for their calibration
+  // tables. Distinguishes "no printers" from "not answered yet": the fetch is
+  // several MQTT round trips per machine, so the gap is seconds, not a frame.
+  isLoading?: boolean;
 }
 
+// Where a filament option came from. Shown as a badge beside the name, using
+// the same wording and colours as the Configure AMS Slot modal, so "which of
+// my four preset sources is this?" reads the same everywhere in the app.
+export type FilamentOptionSource = 'cloud' | 'orca_cloud' | 'local' | 'builtin';
+
 // Filament option from presets
 export interface FilamentOption {
   code: string;
@@ -93,6 +144,7 @@ export interface FilamentOption {
   displayName: string;
   isCustom: boolean;
   allCodes: string[];
+  source: FilamentOptionSource;
 }
 
 // Color preset

+ 83 - 23
frontend/src/components/spool-form/utils.ts

@@ -2,7 +2,7 @@ import { api } from '../../api/client';
 import type { SlicerSetting, LocalPreset, BuiltinFilament } from '../../api/client';
 import { installedNozzleDiameters } from '../../utils/amsHelpers';
 import type { CalibrationProfile, ColorPreset, FilamentOption } from './types';
-import { KNOWN_VARIANTS, DEFAULT_BRANDS, RECENT_COLORS_KEY, MAX_RECENT_COLORS } from './constants';
+import { KNOWN_VARIANTS, DEFAULT_BRANDS, RECENT_COLORS_KEY, MAX_RECENT_COLORS, STANDARD_NOZZLE_DIAMETERS } from './constants';
 
 /**
  * Fetch a printer's K-profiles across every nozzle it actually has installed
@@ -19,11 +19,30 @@ export async function fetchPrinterCalibrations(
   printerId: number,
   status: { nozzles?: { nozzle_diameter?: string }[] } | null | undefined,
 ): Promise<CalibrationProfile[]> {
-  const diameters = installedNozzleDiameters(status);
-  const toFetch = diameters.length > 0 ? diameters : ['0.4'];
-  const responses = await Promise.all(
-    toFetch.map(d => api.getKProfiles(printerId, d).catch(() => null)),
-  );
+  // Every standard size, plus anything unusual the printer reports as fitted --
+  // not just the fitted ones. A K profile is stored on the printer per nozzle
+  // diameter and survives a nozzle swap, so fetching only what is screwed in
+  // right now made a 0.6 profile invisible until a 0.6 was fitted, and left the
+  // user unable to prepare a spool for a nozzle they are about to change to.
+  // A diameter the printer has nothing for answers with an empty list rather
+  // than timing out, so the extra sizes cost a round trip each, not a stall --
+  // provided they are sent one at a time, see below.
+  const installed = installedNozzleDiameters(status);
+  const toFetch = Array.from(new Set([...STANDARD_NOZZLE_DIAMETERS, ...installed]));
+
+  // One diameter at a time, NOT in parallel. H2-series firmware answers only
+  // the first one or two of a concurrent burst of `extrusion_cali_get` and
+  // silently drops the rest, so each dropped request costs a 5s timeout before
+  // it is retried: measured on an H2C and an H2D, four parallel requests took
+  // 11s and 23s respectively, against ~1s when sent one after another. (An X1C
+  // answers all four concurrently, which is why this went unnoticed while only
+  // dual-diameter printers ever sent more than one.) Per-diameter failures are
+  // still swallowed: a printer that doesn't support the endpoint yields no rows
+  // rather than losing the diameters that did answer.
+  const responses = [];
+  for (const diameter of toFetch) {
+    responses.push(await api.getKProfiles(printerId, diameter).catch(() => null));
+  }
   const calibrations: CalibrationProfile[] = [];
   for (const res of responses) {
     if (!res) continue;
@@ -37,6 +56,7 @@ export async function fetchPrinterCalibrations(
         n_coef: parseFloat(p.n_coef) || 0,
         extruder_id: p.extruder_id,
         nozzle_diameter: p.nozzle_diameter,
+        nozzle_id: p.nozzle_id,
       });
     }
   }
@@ -45,23 +65,23 @@ export async function fetchPrinterCalibrations(
 
 // Fallback filament presets when cloud is not available
 const FALLBACK_PRESETS: FilamentOption[] = [
-  { code: 'GFL00', name: 'Bambu PLA Basic', displayName: 'Bambu PLA Basic', isCustom: false, allCodes: ['GFL00'] },
-  { code: 'GFL01', name: 'Bambu PLA Matte', displayName: 'Bambu PLA Matte', isCustom: false, allCodes: ['GFL01'] },
-  { code: 'GFL05', name: 'Generic PLA', displayName: 'Generic PLA', isCustom: false, allCodes: ['GFL05'] },
-  { code: 'GFG00', name: 'Bambu PETG Basic', displayName: 'Bambu PETG Basic', isCustom: false, allCodes: ['GFG00'] },
-  { code: 'GFG05', name: 'Generic PETG', displayName: 'Generic PETG', isCustom: false, allCodes: ['GFG05'] },
-  { code: 'GFB00', name: 'Bambu ABS Basic', displayName: 'Bambu ABS Basic', isCustom: false, allCodes: ['GFB00'] },
-  { code: 'GFB05', name: 'Generic ABS', displayName: 'Generic ABS', isCustom: false, allCodes: ['GFB05'] },
-  { code: 'GFA00', name: 'Bambu ASA Basic', displayName: 'Bambu ASA Basic', isCustom: false, allCodes: ['GFA00'] },
-  { code: 'GFU00', name: 'Bambu TPU 95A', displayName: 'Bambu TPU 95A', isCustom: false, allCodes: ['GFU00'] },
-  { code: 'GFU05', name: 'Generic TPU', displayName: 'Generic TPU', isCustom: false, allCodes: ['GFU05'] },
-  { code: 'GFC00', name: 'Bambu PC Basic', displayName: 'Bambu PC Basic', isCustom: false, allCodes: ['GFC00'] },
-  { code: 'GFN00', name: 'Bambu PA Basic', displayName: 'Bambu PA Basic', isCustom: false, allCodes: ['GFN00'] },
-  { code: 'GFN05', name: 'Generic PA', displayName: 'Generic PA', isCustom: false, allCodes: ['GFN05'] },
-  { code: 'GFS00', name: 'Bambu PLA-CF', displayName: 'Bambu PLA-CF', isCustom: false, allCodes: ['GFS00'] },
-  { code: 'GFT00', name: 'Bambu PETG-CF', displayName: 'Bambu PETG-CF', isCustom: false, allCodes: ['GFT00'] },
-  { code: 'GFNC0', name: 'Bambu PA-CF', displayName: 'Bambu PA-CF', isCustom: false, allCodes: ['GFNC0'] },
-  { code: 'GFV00', name: 'Bambu PVA', displayName: 'Bambu PVA', isCustom: false, allCodes: ['GFV00'] },
+  { code: 'GFL00', name: 'Bambu PLA Basic', displayName: 'Bambu PLA Basic', isCustom: false, allCodes: ['GFL00'], source: 'builtin' },
+  { code: 'GFL01', name: 'Bambu PLA Matte', displayName: 'Bambu PLA Matte', isCustom: false, allCodes: ['GFL01'], source: 'builtin' },
+  { code: 'GFL05', name: 'Generic PLA', displayName: 'Generic PLA', isCustom: false, allCodes: ['GFL05'], source: 'builtin' },
+  { code: 'GFG00', name: 'Bambu PETG Basic', displayName: 'Bambu PETG Basic', isCustom: false, allCodes: ['GFG00'], source: 'builtin' },
+  { code: 'GFG05', name: 'Generic PETG', displayName: 'Generic PETG', isCustom: false, allCodes: ['GFG05'], source: 'builtin' },
+  { code: 'GFB00', name: 'Bambu ABS Basic', displayName: 'Bambu ABS Basic', isCustom: false, allCodes: ['GFB00'], source: 'builtin' },
+  { code: 'GFB05', name: 'Generic ABS', displayName: 'Generic ABS', isCustom: false, allCodes: ['GFB05'], source: 'builtin' },
+  { code: 'GFA00', name: 'Bambu ASA Basic', displayName: 'Bambu ASA Basic', isCustom: false, allCodes: ['GFA00'], source: 'builtin' },
+  { code: 'GFU00', name: 'Bambu TPU 95A', displayName: 'Bambu TPU 95A', isCustom: false, allCodes: ['GFU00'], source: 'builtin' },
+  { code: 'GFU05', name: 'Generic TPU', displayName: 'Generic TPU', isCustom: false, allCodes: ['GFU05'], source: 'builtin' },
+  { code: 'GFC00', name: 'Bambu PC Basic', displayName: 'Bambu PC Basic', isCustom: false, allCodes: ['GFC00'], source: 'builtin' },
+  { code: 'GFN00', name: 'Bambu PA Basic', displayName: 'Bambu PA Basic', isCustom: false, allCodes: ['GFN00'], source: 'builtin' },
+  { code: 'GFN05', name: 'Generic PA', displayName: 'Generic PA', isCustom: false, allCodes: ['GFN05'], source: 'builtin' },
+  { code: 'GFS00', name: 'Bambu PLA-CF', displayName: 'Bambu PLA-CF', isCustom: false, allCodes: ['GFS00'], source: 'builtin' },
+  { code: 'GFT00', name: 'Bambu PETG-CF', displayName: 'Bambu PETG-CF', isCustom: false, allCodes: ['GFT00'], source: 'builtin' },
+  { code: 'GFNC0', name: 'Bambu PA-CF', displayName: 'Bambu PA-CF', isCustom: false, allCodes: ['GFNC0'], source: 'builtin' },
+  { code: 'GFV00', name: 'Bambu PVA', displayName: 'Bambu PVA', isCustom: false, allCodes: ['GFV00'], source: 'builtin' },
 ];
 
 // Parse a slicer preset name to extract brand, material, and variant
@@ -189,6 +209,7 @@ function buildLocalFilamentOptions(localPresets: LocalPreset[]): FilamentOption[
       displayName: preset.name,
       isCustom: false,
       allCodes,
+      source: 'local',
     };
   });
   return options.sort((a, b) => a.displayName.localeCompare(b.displayName));
@@ -204,6 +225,12 @@ export function buildFilamentOptions(
   configuredPrinterModels: Set<string>,
   localPresets?: LocalPreset[],
   builtinFilaments?: BuiltinFilament[],
+  // Which of the cloud presets came from Orca Cloud rather than Bambu Cloud.
+  // The two are merged into one list by the callers (OrcaProfileMeta is
+  // structurally identical to SlicerSetting), so the distinction has to be
+  // carried in rather than derived -- and it is worth carrying, because the
+  // origin badge is the only thing telling a user which cloud a preset is in.
+  orcaSettingIds?: Set<string>,
 ): FilamentOption[] {
   const customPresets: FilamentOption[] = [];
   const defaultPresets: FilamentOption[] = [];
@@ -227,6 +254,7 @@ export function buildFilamentOptions(
           displayName: `${preset.name} (Custom)`,
           isCustom: true,
           allCodes: [preset.setting_id],
+          source: orcaSettingIds?.has(preset.setting_id) ? 'orca_cloud' : 'cloud',
         });
         cloudCodes.add(preset.setting_id);
       }
@@ -237,6 +265,7 @@ export function buildFilamentOptions(
         displayName: preset.name,
         isCustom: false,
         allCodes: [preset.setting_id],
+        source: orcaSettingIds?.has(preset.setting_id) ? 'orca_cloud' : 'cloud',
       });
       cloudCodes.add(preset.setting_id);
     }
@@ -263,6 +292,7 @@ export function buildFilamentOptions(
         displayName: bf.name,
         isCustom: false,
         allCodes: [bf.filament_id, settingId],
+        source: 'builtin',
       });
     }
   }
@@ -423,6 +453,36 @@ export function genericFilamentIdMatchesMaterial(id: string, material: string):
 }
 
 // Check if a calibration matches based on brand, material, and variant
+/**
+ * Key one hotend's K-profile selection: printer, extruder, nozzle diameter.
+ *
+ * All three because that is what both K tables are keyed on -- a K value is
+ * measured on one individual hotend, and cali_idx alone cannot identify one
+ * (the printer numbers its calibration table PER NOZZLE, so index 16 exists on
+ * both hotends of a dual-nozzle machine meaning different things).
+ */
+export function hotendKey(printerId: number, extruder: number, diameter: string): string {
+  return `${printerId}:${extruder}:${diameter}`;
+}
+
+/**
+ * Key one per-printer-model preset override.
+ *
+ * Diameter "" is the model's own default and a bare decimal is a per-hotend
+ * exception, matching the (printer_model, nozzle_diameter) pair the backend
+ * stores. The separator is a NUL because a model name is free text from the
+ * printer ("A1 mini") and must never be able to collide with a diameter.
+ */
+export function presetKey(model: string, diameter: string): string {
+  return `${model}\u0000${diameter}`;
+}
+
+/** Split a `presetKey` back into its model and diameter. */
+export function parsePresetKey(key: string): { model: string; diameter: string } {
+  const [model = '', diameter = ''] = key.split('\u0000');
+  return { model, diameter };
+}
+
 export function isMatchingCalibration(
   cal: { name?: string; filament_id?: string },
   formData: { material: string; brand: string; subtype: string; slicer_filament?: string },

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

@@ -4777,6 +4777,8 @@ export default {
     advancedSettings: 'Erweiterte Einstellungen',
     filamentInfoTab: 'Filament-Info',
     paProfileTab: 'PA-Profil',
+    colorAndCostTab: 'Farbe & Kosten',
+    printersTab: 'Drucker',
     filamentInfo: 'Filament',
     additional: 'Zusätzlich',
     loadingPresets: 'Cloud-Presets werden geladen...',
@@ -4833,6 +4835,23 @@ export default {
     leftNozzle: 'Linke Düse',
     rightNozzle: 'Rechte Düse',
     profilesSelected: 'Kalibrierungsprofil(e) ausgewählt',
+    // Printers tab: per-model filament preset + per-hotend K profile
+    filamentPreset: 'Filament-Voreinstellung',
+    presetUseSpoolDefault: 'Voreinstellung der Spule verwenden',
+    presetInherited: 'geerbt',
+    presetOverride: 'überschrieben',
+    presetNeedsModel: 'Dieser Drucker hat sein Modell noch nicht gemeldet und kann daher keine eigene Voreinstellung haben.',
+    autoMatchPresets: 'Automatisch zuordnen',
+    autoMatchPresetsHint: 'Die Variante der Spulen-Voreinstellung suchen, die das jeweilige Modell nennt',
+    kProfilesPerPrinter: 'K-Profile',
+    kProfileNotSet: 'Nicht gesetzt',
+    kProfileFlowMismatch: 'Dieses Profil wurde mit einer {{profile}}-Düse gemessen, eingebaut ist aber eine {{fitted}}-Düse — es wird daher nicht angewendet',
+    nozzle: 'Düse',
+    unknownModel: 'Unbekanntes Modell',
+    onePrinter: '1 Drucker',
+    nPrinters: '{{n}} Drucker',
+    filamentPresetsLoadFailed: 'Die modellspezifischen Voreinstellungen dieser Spule konnten nicht geladen werden',
+    filamentPresetSaveFailed: 'Die modellspezifischen Voreinstellungen konnten nicht gespeichert werden',
     // Stats & enhanced table
     totalInventory: 'Gesamtbestand',
     totalConsumed: 'Gesamtverbrauch',

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

@@ -4819,6 +4819,8 @@ export default {
     // Tabs
     filamentInfoTab: 'Filament Info',
     paProfileTab: 'PA Profile',
+    colorAndCostTab: 'Color & Cost',
+    printersTab: 'Printers',
     filamentInfo: 'Filament',
     additional: 'Additional',
     // Cloud
@@ -4878,6 +4880,23 @@ export default {
     leftNozzle: 'Left Nozzle',
     rightNozzle: 'Right Nozzle',
     profilesSelected: 'calibration profile(s) selected',
+    // Printers tab: per-model filament preset + per-hotend K profile
+    filamentPreset: 'Filament preset',
+    presetUseSpoolDefault: 'Use the spool\'s preset',
+    presetInherited: 'inherited',
+    presetOverride: 'override',
+    presetNeedsModel: 'This printer has not reported its model yet, so it cannot have its own preset.',
+    autoMatchPresets: 'Auto-match',
+    autoMatchPresetsHint: 'Find the variant of this spool\'s preset that names each model',
+    kProfilesPerPrinter: 'K profiles',
+    kProfileNotSet: 'Not set',
+    kProfileFlowMismatch: 'This profile was measured on a {{profile}} nozzle but a {{fitted}} nozzle is fitted, so it will not be applied',
+    nozzle: 'Nozzle',
+    unknownModel: 'Unknown model',
+    onePrinter: '1 printer',
+    nPrinters: '{{n}} printers',
+    filamentPresetsLoadFailed: 'Could not load this spool\'s per-model presets',
+    filamentPresetSaveFailed: 'Could not save the per-model presets',
     // Stats & enhanced table
     totalInventory: 'Total Inventory',
     totalConsumed: 'Total Consumed',

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

@@ -4781,6 +4781,8 @@ export default {
     // Tabs
     filamentInfoTab: 'Información del filamento',
     paProfileTab: 'Perfil PA',
+    colorAndCostTab: 'Color y coste',
+    printersTab: 'Impresoras',
     filamentInfo: 'Filamento',
     additional: 'Adicional',
     // Cloud
@@ -4840,6 +4842,23 @@ export default {
     leftNozzle: 'Boquilla izquierda',
     rightNozzle: 'Boquilla derecha',
     profilesSelected: 'perfil(es) de calibración seleccionado(s)',
+    // Printers tab: per-model filament preset + per-hotend K profile
+    filamentPreset: 'Perfil de filamento',
+    presetUseSpoolDefault: 'Usar el perfil de la bobina',
+    presetInherited: 'heredado',
+    presetOverride: 'personalizado',
+    presetNeedsModel: 'Esta impresora aún no ha indicado su modelo, así que no puede tener su propio perfil.',
+    autoMatchPresets: 'Asignar automáticamente',
+    autoMatchPresetsHint: 'Buscar la variante del perfil de la bobina que nombra cada modelo',
+    kProfilesPerPrinter: 'Perfiles K',
+    kProfileNotSet: 'Sin definir',
+    kProfileFlowMismatch: 'Este perfil se midió con una boquilla {{profile}} pero hay montada una {{fitted}}, así que no se aplicará',
+    nozzle: 'Boquilla',
+    unknownModel: 'Modelo desconocido',
+    onePrinter: '1 impresora',
+    nPrinters: '{{n}} impresoras',
+    filamentPresetsLoadFailed: 'No se pudieron cargar los perfiles por modelo de esta bobina',
+    filamentPresetSaveFailed: 'No se pudieron guardar los perfiles por modelo',
     // Stats & enhanced table
     totalInventory: 'Inventario total',
     totalConsumed: 'Total consumido',

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

@@ -4766,6 +4766,8 @@ export default {
     // Tabs
     filamentInfoTab: 'Infos Filament',
     paProfileTab: 'Profil PA',
+    colorAndCostTab: 'Couleur et coût',
+    printersTab: 'Imprimantes',
     filamentInfo: 'Filament',
     additional: 'Additionnel',
     // Cloud
@@ -4822,6 +4824,23 @@ export default {
     leftNozzle: 'Buse Gauche',
     rightNozzle: 'Buse Droite',
     profilesSelected: 'profil(s) de calibration sélectionné(s)',
+    // Printers tab: per-model filament preset + per-hotend K profile
+    filamentPreset: 'Préréglage de filament',
+    presetUseSpoolDefault: 'Utiliser le préréglage de la bobine',
+    presetInherited: 'hérité',
+    presetOverride: 'personnalisé',
+    presetNeedsModel: 'Cette imprimante n\'a pas encore indiqué son modèle, elle ne peut donc pas avoir son propre préréglage.',
+    autoMatchPresets: 'Association automatique',
+    autoMatchPresetsHint: 'Trouver la variante du préréglage de la bobine qui nomme chaque modèle',
+    kProfilesPerPrinter: 'Profils K',
+    kProfileNotSet: 'Non défini',
+    kProfileFlowMismatch: 'Ce profil a été mesuré avec une buse {{profile}} alors qu\'une buse {{fitted}} est installée : il ne sera pas appliqué',
+    nozzle: 'Buse',
+    unknownModel: 'Modèle inconnu',
+    onePrinter: '1 imprimante',
+    nPrinters: '{{n}} imprimantes',
+    filamentPresetsLoadFailed: 'Impossible de charger les préréglages par modèle de cette bobine',
+    filamentPresetSaveFailed: 'Impossible d\'enregistrer les préréglages par modèle',
     // Stats & enhanced table
     totalInventory: 'Total Inventaire',
     totalConsumed: 'Total Consommé',

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

@@ -4765,6 +4765,8 @@ export default {
     // Tabs
     filamentInfoTab: 'Info filamento',
     paProfileTab: 'Profilo PA',
+    colorAndCostTab: 'Colore e costo',
+    printersTab: 'Stampanti',
     filamentInfo: 'Filamento',
     additional: 'Aggiuntivo',
     // Cloud
@@ -4821,6 +4823,23 @@ export default {
     leftNozzle: 'Ugello sinistro',
     rightNozzle: 'Ugello destro',
     profilesSelected: 'profili di calibrazione selezionati',
+    // Printers tab: per-model filament preset + per-hotend K profile
+    filamentPreset: 'Preset del filamento',
+    presetUseSpoolDefault: 'Usa il preset della bobina',
+    presetInherited: 'ereditato',
+    presetOverride: 'personalizzato',
+    presetNeedsModel: 'Questa stampante non ha ancora comunicato il proprio modello, quindi non può avere un preset dedicato.',
+    autoMatchPresets: 'Abbina automaticamente',
+    autoMatchPresetsHint: 'Trova la variante del preset della bobina che nomina ciascun modello',
+    kProfilesPerPrinter: 'Profili K',
+    kProfileNotSet: 'Non impostato',
+    kProfileFlowMismatch: 'Questo profilo è stato misurato con un ugello {{profile}} ma è montato un ugello {{fitted}}, quindi non verrà applicato',
+    nozzle: 'Ugello',
+    unknownModel: 'Modello sconosciuto',
+    onePrinter: '1 stampante',
+    nPrinters: '{{n}} stampanti',
+    filamentPresetsLoadFailed: 'Impossibile caricare i preset per modello di questa bobina',
+    filamentPresetSaveFailed: 'Impossibile salvare i preset per modello',
     // Stats & enhanced table
     totalInventory: 'Inventario totale',
     totalConsumed: 'Totale consumato',

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

@@ -4777,6 +4777,8 @@ export default {
     // Tabs
     filamentInfoTab: 'フィラメント情報',
     paProfileTab: 'PAプロファイル',
+    colorAndCostTab: '色とコスト',
+    printersTab: 'プリンター',
     filamentInfo: 'フィラメント',
     additional: '追加情報',
     // Cloud
@@ -4833,6 +4835,23 @@ export default {
     leftNozzle: '左ノズル',
     rightNozzle: '右ノズル',
     profilesSelected: 'キャリブレーションプロファイル選択済み',
+    // Printers tab: per-model filament preset + per-hotend K profile
+    filamentPreset: 'フィラメントプリセット',
+    presetUseSpoolDefault: 'スプールのプリセットを使用',
+    presetInherited: '継承',
+    presetOverride: '上書き',
+    presetNeedsModel: 'このプリンターはまだモデルを報告していないため、専用のプリセットを設定できません。',
+    autoMatchPresets: '自動で割り当て',
+    autoMatchPresetsHint: '各モデル名を含むスプールプリセットのバリアントを探します',
+    kProfilesPerPrinter: 'Kプロファイル',
+    kProfileNotSet: '未設定',
+    kProfileFlowMismatch: 'このプロファイルは{{profile}}ノズルで測定されましたが、装着されているのは{{fitted}}ノズルのため適用されません',
+    nozzle: 'ノズル',
+    unknownModel: '不明なモデル',
+    onePrinter: 'プリンター1台',
+    nPrinters: 'プリンター{{n}}台',
+    filamentPresetsLoadFailed: 'このスプールのモデル別プリセットを読み込めませんでした',
+    filamentPresetSaveFailed: 'モデル別プリセットを保存できませんでした',
     // Stats & enhanced table
     totalInventory: '在庫合計',
     totalConsumed: '総消費量',

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

@@ -4561,6 +4561,8 @@ export default {
     advancedSettings: '고급 설정',
     filamentInfoTab: '필라멘트 정보',
     paProfileTab: 'PA 프로필',
+    colorAndCostTab: '색상 및 비용',
+    printersTab: '프린터',
     filamentInfo: '필라멘트',
     additional: '추가',
     loadingPresets: '클라우드 프리셋 불러오는 중...',
@@ -4613,6 +4615,23 @@ export default {
     leftNozzle: '왼쪽 노즐',
     rightNozzle: '오른쪽 노즐',
     profilesSelected: '보정 프로필 선택됨',
+    // Printers tab: per-model filament preset + per-hotend K profile
+    filamentPreset: '필라멘트 프리셋',
+    presetUseSpoolDefault: '스풀 프리셋 사용',
+    presetInherited: '상속됨',
+    presetOverride: '재정의',
+    presetNeedsModel: '이 프린터는 아직 모델을 보고하지 않아 전용 프리셋을 지정할 수 없습니다.',
+    autoMatchPresets: '자동 매칭',
+    autoMatchPresetsHint: '각 모델 이름이 들어간 스풀 프리셋 변형을 찾습니다',
+    kProfilesPerPrinter: 'K 프로파일',
+    kProfileNotSet: '설정 안 됨',
+    kProfileFlowMismatch: '이 프로파일은 {{profile}} 노즐에서 측정되었지만 장착된 노즐은 {{fitted}}이므로 적용되지 않습니다',
+    nozzle: '노즐',
+    unknownModel: '알 수 없는 모델',
+    onePrinter: '프린터 1대',
+    nPrinters: '프린터 {{n}}대',
+    filamentPresetsLoadFailed: '이 스풀의 모델별 프리셋을 불러오지 못했습니다',
+    filamentPresetSaveFailed: '모델별 프리셋을 저장하지 못했습니다',
     totalInventory: '총 재고',
     totalConsumed: '총 소비량',
     byMaterial: '재료별',

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

@@ -4819,6 +4819,8 @@ export default {
     // Tabs
     filamentInfoTab: 'Filamentinformatie',
     paProfileTab: 'PA-profiel',
+    colorAndCostTab: 'Kleur & kosten',
+    printersTab: 'Printers',
     filamentInfo: 'Filament',
     additional: 'Aanvullend',
     // Cloud
@@ -4878,6 +4880,23 @@ export default {
     leftNozzle: 'Linker nozzle',
     rightNozzle: 'Rechter nozzle',
     profilesSelected: 'kalibratieprofiel(en) geselecteerd',
+    // Printers tab: per-model filament preset + per-hotend K profile
+    filamentPreset: 'Filamentvoorinstelling',
+    presetUseSpoolDefault: 'Voorinstelling van de spoel gebruiken',
+    presetInherited: 'overgenomen',
+    presetOverride: 'aangepast',
+    presetNeedsModel: 'Deze printer heeft zijn model nog niet doorgegeven en kan dus geen eigen voorinstelling hebben.',
+    autoMatchPresets: 'Automatisch koppelen',
+    autoMatchPresetsHint: 'Zoek de variant van de spoelvoorinstelling die elk model noemt',
+    kProfilesPerPrinter: 'K-profielen',
+    kProfileNotSet: 'Niet ingesteld',
+    kProfileFlowMismatch: 'Dit profiel is gemeten met een {{profile}}-nozzle, maar er is een {{fitted}}-nozzle gemonteerd; het wordt dus niet toegepast',
+    nozzle: 'Nozzle',
+    unknownModel: 'Onbekend model',
+    onePrinter: '1 printer',
+    nPrinters: '{{n}} printers',
+    filamentPresetsLoadFailed: 'De voorinstellingen per model van deze spoel konden niet worden geladen',
+    filamentPresetSaveFailed: 'De voorinstellingen per model konden niet worden opgeslagen',
     // Stats & enhanced table
     totalInventory: 'Totale voorraad',
     totalConsumed: 'Totaal verbruikt',

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

@@ -4765,6 +4765,8 @@ export default {
     // Tabs
     filamentInfoTab: 'Informações do Filamento',
     paProfileTab: 'Perfil PA',
+    colorAndCostTab: 'Cor e custo',
+    printersTab: 'Impressoras',
     filamentInfo: 'Filamento',
     additional: 'Adicional',
     // Cloud
@@ -4821,6 +4823,23 @@ export default {
     leftNozzle: 'Bico Esquerdo',
     rightNozzle: 'Bico Direito',
     profilesSelected: 'perfil(is) de calibração selecionado(s)',
+    // Printers tab: per-model filament preset + per-hotend K profile
+    filamentPreset: 'Predefinição de filamento',
+    presetUseSpoolDefault: 'Usar a predefinição do carretel',
+    presetInherited: 'herdada',
+    presetOverride: 'personalizada',
+    presetNeedsModel: 'Esta impressora ainda não informou o modelo, portanto não pode ter uma predefinição própria.',
+    autoMatchPresets: 'Associar automaticamente',
+    autoMatchPresetsHint: 'Encontrar a variante da predefinição do carretel que nomeia cada modelo',
+    kProfilesPerPrinter: 'Perfis K',
+    kProfileNotSet: 'Não definido',
+    kProfileFlowMismatch: 'Este perfil foi medido com um bico {{profile}}, mas há um bico {{fitted}} instalado, portanto não será aplicado',
+    nozzle: 'Bico',
+    unknownModel: 'Modelo desconhecido',
+    onePrinter: '1 impressora',
+    nPrinters: '{{n}} impressoras',
+    filamentPresetsLoadFailed: 'Não foi possível carregar as predefinições por modelo deste carretel',
+    filamentPresetSaveFailed: 'Não foi possível salvar as predefinições por modelo',
     // Stats & enhanced table
     totalInventory: 'Inventário Total',
     totalConsumed: 'Total Consumido',

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

@@ -4552,6 +4552,8 @@ export default {
     advancedSettings: "Расширенные настройки",
     filamentInfoTab: "Сведения о филаменте",
     paProfileTab: "Профиль PA",
+    colorAndCostTab: 'Цвет и стоимость',
+    printersTab: 'Принтеры',
     filamentInfo: "Филамент",
     additional: "Дополнительно",
     loadingPresets: "Загрузка облачных пресетов…",
@@ -4604,6 +4606,23 @@ export default {
     leftNozzle: "Левое сопло",
     rightNozzle: "Правое сопло",
     profilesSelected: "выбрано калибровочных профилей",
+    // Printers tab: per-model filament preset + per-hotend K profile
+    filamentPreset: 'Пресет филамента',
+    presetUseSpoolDefault: 'Использовать пресет катушки',
+    presetInherited: 'наследуется',
+    presetOverride: 'переопределён',
+    presetNeedsModel: 'Этот принтер ещё не сообщил свою модель, поэтому у него не может быть собственного пресета.',
+    autoMatchPresets: 'Подобрать автоматически',
+    autoMatchPresetsHint: 'Найти вариант пресета катушки, в названии которого указана каждая модель',
+    kProfilesPerPrinter: 'K-профили',
+    kProfileNotSet: 'Не задан',
+    kProfileFlowMismatch: 'Этот профиль измерен на сопле {{profile}}, но установлено сопло {{fitted}}, поэтому он не будет применён',
+    nozzle: 'Сопло',
+    unknownModel: 'Неизвестная модель',
+    onePrinter: '1 принтер',
+    nPrinters: 'Принтеров: {{n}}',
+    filamentPresetsLoadFailed: 'Не удалось загрузить пресеты по моделям для этой катушки',
+    filamentPresetSaveFailed: 'Не удалось сохранить пресеты по моделям',
     totalInventory: "Всего в учёте",
     totalConsumed: "Израсходовано всего",
     byMaterial: "По материалам",

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

@@ -4764,6 +4764,8 @@ export default {
     advancedSettings: 'Gelişmiş Ayarlar',
     filamentInfoTab: 'Filament Bilgisi',
     paProfileTab: 'PA Profili',
+    colorAndCostTab: 'Renk ve maliyet',
+    printersTab: 'Yazıcılar',
     filamentInfo: 'Filament',
     additional: 'Ek',
     loadingPresets: 'Bulut ön ayarları yükleniyor...',
@@ -4816,6 +4818,23 @@ export default {
     leftNozzle: 'Sol Nozul',
     rightNozzle: 'Sağ Nozul',
     profilesSelected: 'kalibrasyon profili seçildi',
+    // Printers tab: per-model filament preset + per-hotend K profile
+    filamentPreset: 'Filament ön ayarı',
+    presetUseSpoolDefault: 'Makaranın ön ayarını kullan',
+    presetInherited: 'devralındı',
+    presetOverride: 'geçersiz kılındı',
+    presetNeedsModel: 'Bu yazıcı modelini henüz bildirmedi, bu yüzden kendi ön ayarı olamaz.',
+    autoMatchPresets: 'Otomatik eşleştir',
+    autoMatchPresetsHint: 'Makara ön ayarının her modeli adıyla anan çeşidini bul',
+    kProfilesPerPrinter: 'K profilleri',
+    kProfileNotSet: 'Ayarlanmadı',
+    kProfileFlowMismatch: 'Bu profil {{profile}} nozul ile ölçüldü ancak takılı nozul {{fitted}}, bu yüzden uygulanmayacak',
+    nozzle: 'Nozul',
+    unknownModel: 'Bilinmeyen model',
+    onePrinter: '1 yazıcı',
+    nPrinters: '{{n}} yazıcı',
+    filamentPresetsLoadFailed: 'Bu makaranın modele özel ön ayarları yüklenemedi',
+    filamentPresetSaveFailed: 'Modele özel ön ayarlar kaydedilemedi',
     totalInventory: 'Toplam Envanter',
     totalConsumed: 'Toplam Tüketim',
     byMaterial: 'Malzemeye Göre',

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

@@ -4816,6 +4816,8 @@ export default {
     // Tabs
     filamentInfoTab: "Інформація про філамент",
     paProfileTab: "Профіль PA",
+    colorAndCostTab: 'Колір і вартість',
+    printersTab: 'Принтери',
     filamentInfo: "Філамент",
     additional: "Додатково",
     // Cloud
@@ -4875,6 +4877,23 @@ export default {
     leftNozzle: "Ліве сопло",
     rightNozzle: "Праве сопло",
     profilesSelected: "вибрані профілі калібрування",
+    // Printers tab: per-model filament preset + per-hotend K profile
+    filamentPreset: 'Пресет філаменту',
+    presetUseSpoolDefault: 'Використовувати пресет котушки',
+    presetInherited: 'успадковано',
+    presetOverride: 'перевизначено',
+    presetNeedsModel: 'Цей принтер ще не повідомив свою модель, тому не може мати власний пресет.',
+    autoMatchPresets: 'Підібрати автоматично',
+    autoMatchPresetsHint: 'Знайти варіант пресета котушки, у назві якого вказано кожну модель',
+    kProfilesPerPrinter: 'K-профілі',
+    kProfileNotSet: 'Не задано',
+    kProfileFlowMismatch: 'Цей профіль виміряно на соплі {{profile}}, але встановлено сопло {{fitted}}, тому його не буде застосовано',
+    nozzle: 'Сопло',
+    unknownModel: 'Невідома модель',
+    onePrinter: '1 принтер',
+    nPrinters: 'Принтерів: {{n}}',
+    filamentPresetsLoadFailed: 'Не вдалося завантажити пресети за моделями для цієї котушки',
+    filamentPresetSaveFailed: 'Не вдалося зберегти пресети за моделями',
     // Stats & enhanced table
     totalInventory: "Загальний запас",
     totalConsumed: "Всього спожито",

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

@@ -4771,6 +4771,8 @@ export default {
     // Tabs
     filamentInfoTab: '耗材信息',
     paProfileTab: 'PA 配置',
+    colorAndCostTab: '颜色与成本',
+    printersTab: '打印机',
     filamentInfo: '耗材',
     additional: '附加',
     // Cloud
@@ -4827,6 +4829,23 @@ export default {
     leftNozzle: '左喷嘴',
     rightNozzle: '右喷嘴',
     profilesSelected: '个校准配置已选择',
+    // Printers tab: per-model filament preset + per-hotend K profile
+    filamentPreset: '耗材预设',
+    presetUseSpoolDefault: '使用料卷的预设',
+    presetInherited: '继承',
+    presetOverride: '已覆盖',
+    presetNeedsModel: '该打印机尚未上报机型,因此无法设置专属预设。',
+    autoMatchPresets: '自动匹配',
+    autoMatchPresetsHint: '查找料卷预设中标明各机型的对应版本',
+    kProfilesPerPrinter: 'K 值配置',
+    kProfileNotSet: '未设置',
+    kProfileFlowMismatch: '此配置是在 {{profile}} 喷嘴上测得的,但当前装的是 {{fitted}} 喷嘴,因此不会应用',
+    nozzle: '喷嘴',
+    unknownModel: '未知机型',
+    onePrinter: '1 台打印机',
+    nPrinters: '{{n}} 台打印机',
+    filamentPresetsLoadFailed: '无法加载该料卷的按机型预设',
+    filamentPresetSaveFailed: '无法保存按机型预设',
     // Stats & enhanced table
     totalInventory: '总库存',
     totalConsumed: '总消耗',

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

@@ -4771,6 +4771,8 @@ export default {
     // Tabs
     filamentInfoTab: '耗材資訊',
     paProfileTab: 'PA 設定',
+    colorAndCostTab: '顏色與成本',
+    printersTab: '印表機',
     filamentInfo: '耗材',
     additional: '附加',
     // Cloud
@@ -4827,6 +4829,23 @@ export default {
     leftNozzle: '左噴嘴',
     rightNozzle: '右噴嘴',
     profilesSelected: '個校準設定已選擇',
+    // Printers tab: per-model filament preset + per-hotend K profile
+    filamentPreset: '耗材預設',
+    presetUseSpoolDefault: '使用線材捲的預設',
+    presetInherited: '繼承',
+    presetOverride: '已覆寫',
+    presetNeedsModel: '此印表機尚未回報機型,因此無法設定專屬預設。',
+    autoMatchPresets: '自動比對',
+    autoMatchPresetsHint: '尋找線材捲預設中標示各機型的對應版本',
+    kProfilesPerPrinter: 'K 值設定檔',
+    kProfileNotSet: '未設定',
+    kProfileFlowMismatch: '此設定檔是在 {{profile}} 噴嘴上測得的,但目前裝的是 {{fitted}} 噴嘴,因此不會套用',
+    nozzle: '噴嘴',
+    unknownModel: '未知機型',
+    onePrinter: '1 台印表機',
+    nPrinters: '{{n}} 台印表機',
+    filamentPresetsLoadFailed: '無法載入此線材捲的各機型預設',
+    filamentPresetSaveFailed: '無法儲存各機型預設',
     // Stats & enhanced table
     totalInventory: '總庫存',
     totalConsumed: '總消耗',

+ 10 - 5
frontend/src/pages/PrintersPage.tsx

@@ -176,7 +176,7 @@ import { FileUploadModal } from '../components/FileUploadModal';
 import { PrintModal } from '../components/PrintModal';
 import { PrinterInfoModal } from '../components/PrinterInfoModal';
 import { FeedDirectionModal } from '../components/FeedDirectionModal';
-import { getAmsLabel, getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, isBambuLabSpool, resolveSlotNozzleDiameter, resolveSlotExtruder, formatSlotLabel, FTS_INLET_SIDE } from '../utils/amsHelpers';
+import { getAmsLabel, getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, installedNozzleDiameters, isBambuLabSpool, resolveSlotNozzleDiameter, resolveSlotExtruder, formatSlotLabel, FTS_INLET_SIDE } from '../utils/amsHelpers';
 import { MAX_CHAMBER_TEMP_C, getPrinterImage, getWifiStrength, filterCompatibleQueueItems, isPrinterCurrentlyDispatchable } from '../utils/printer';
 import { FilamentSlotCircle } from '../components/FilamentSlotCircle';
 import { Collapsible } from '../components/Collapsible';
@@ -3929,10 +3929,15 @@ function PrinterCard({
                 </div>
                 <p className="text-sm text-bambu-gray">
                   {printer.model || 'Unknown Model'}
-                  {/* Nozzle Info - only in expanded */}
-                  {viewMode === 'expanded' && status?.nozzles && status.nozzles[0]?.nozzle_diameter && (
-                    <span className="ml-1.5 text-bambu-gray" title={status.nozzles[0].nozzle_type || 'Nozzle'}>
-                      • {status.nozzles[0].nozzle_diameter}mm
+                  {/* Nozzle Info - only in expanded. Every fitted size, not
+                      just nozzles[0]: the array is indexed by extruder, so on a
+                      dual-nozzle machine with two sizes fitted showing the
+                      first entry alone named one hotend and implied it was the
+                      whole printer. Deduplicated, so the usual matching pair
+                      still reads as a single "0.4mm". */}
+                  {viewMode === 'expanded' && installedNozzleDiameters(status).length > 0 && (
+                    <span className="ml-1.5 text-bambu-gray" title={status?.nozzles?.[0]?.nozzle_type || 'Nozzle'}>
+                      • {installedNozzleDiameters(status).join(' / ')}mm
                     </span>
                   )}
                   {viewMode === 'expanded' && maintenanceInfo && maintenanceInfo.total_print_hours > 0 && (

+ 7 - 1
frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx

@@ -669,7 +669,13 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, spoolmanM
 
       const pc = printersWithCalibrations.find(p => p.printer.id === printerId);
       if (pc) {
-        const cal = pc.calibrations.find(c => c.cali_idx === caliIdx);
+        // Match the extruder too, not cali_idx alone: the printer numbers its
+        // calibration table PER NOZZLE, so on a dual-nozzle machine the same
+        // cali_idx exists on both hotends and means different things. Resolving
+        // by index alone could persist the other hotend's K value and diameter.
+        const cal = pc.calibrations.find(
+          c => c.cali_idx === caliIdx && (c.extruder_id ?? 0) === extruder,
+        );
         if (cal) {
           profiles.push({
             printer_id: printerId,

+ 8 - 4
frontend/src/utils/amsHelpers.ts

@@ -590,10 +590,14 @@ export function installedNozzleDiameters(
  * the machine instead of assuming 0.4mm (#1899).
  *
  * On dual-nozzle printers (H2D) each AMS is bound to one extruder via
- * `ams_extruder_map` (amsId → extruder index, 0=left/primary, 1=right), so we
- * read that nozzle's diameter. Single-nozzle printers have no map entry and
- * fall back to the primary nozzle (index 0). Returns undefined when the printer
- * hasn't reported nozzle hardware yet, letting the caller keep its own default.
+ * `ams_extruder_map` (amsId → extruder index), so we read that nozzle's
+ * diameter. `status.nozzles` is indexed by extruder id -- [0] is the RIGHT
+ * hotend and [1] the left, measured on an H2D fitted with 0.4 left / 0.6 right
+ * -- so indexing it by the extruder is correct. (This comment used to say
+ * "0=left/primary, 1=right", which was backwards; the code was always right.)
+ * Single-nozzle printers have no map entry and fall back to index 0. Returns
+ * undefined when the printer hasn't reported nozzle hardware yet, letting the
+ * caller keep its own default.
  * Diameter is the bare decimal string the status carries, e.g. "0.4" / "0.6".
  */
 export function resolveSlotNozzleDiameter(

+ 57 - 0
frontend/src/utils/nozzleFlow.ts

@@ -0,0 +1,57 @@
+/**
+ * Nozzle flow type: High Flow vs Standard.
+ *
+ * A printer files each calibration profile under a nozzle id of the form
+ * `HH00-0.4` (high flow) or `HS00-0.4` (standard), so on a machine that sells
+ * both, the flow is part of a K profile's identity — the same filament reads a
+ * different K value through each.
+ *
+ * Two spellings have to reduce to the same answer, which is why this compares
+ * two characters rather than four:
+ *
+ *   - a calibration entry says `HH00-0.4`
+ *   - the fitted nozzle reports its type as `HH01`
+ *
+ * Both measured on an H2D; the trailing digits are a hardware variant that the
+ * calibration table normalises to `00`.
+ *
+ * And it can legitimately be absent. An X1C answers `extrusion_cali_get` with
+ * `nozzle_id: ''` on every profile — measured, all eight — even though the
+ * machine really does take either nozzle. So "no flow" means *unknown*, never
+ * Standard: inventing a value here and then filtering on it would drop every
+ * X1C profile the moment a high-flow nozzle was fitted. (BambuStudio's own
+ * parser defaults a missing id to Standard for *display*, which is fine for a
+ * label and wrong for a lookup key.)
+ */
+
+export type NozzleFlow = 'HH' | 'HS';
+
+/** The flow code in a nozzle id (`HH00-0.4`) or a nozzle type (`HH01`). */
+export function normaliseFlow(raw: string | null | undefined): NozzleFlow | null {
+  const code = (raw ?? '').trim().toUpperCase().slice(0, 2);
+  return code === 'HH' || code === 'HS' ? code : null;
+}
+
+/** Short label for a flow code, or null when there is nothing to say. */
+export function flowLabel(flow: NozzleFlow | null): string | null {
+  if (!flow) return null;
+  return flow === 'HH' ? 'HF' : 'S';
+}
+
+/**
+ * Whether a stored K profile's flow applies to the nozzle now fitted.
+ *
+ * Unknown on either side matches: every profile stored before flow was
+ * recorded has none, as does every profile from a printer whose table omits it.
+ * Mirrors `SlotNozzle.flow_matches` on the backend, which is what actually
+ * decides at assign time — this is the same rule for the picker's benefit.
+ */
+export function flowApplies(
+  storedFlow: string | null | undefined,
+  fittedFlow: string | null | undefined,
+): boolean {
+  const stored = normaliseFlow(storedFlow);
+  const fitted = normaliseFlow(fittedFlow);
+  if (!stored || !fitted) return true;
+  return stored === fitted;
+}

+ 76 - 0
frontend/src/utils/slicerPrinterMatch.ts

@@ -319,3 +319,79 @@ export function presetCompatibility(
   // standard presets that don't carry compatible_printers.
   return classifyByBambuName(preset.name, selectedPrinterName, index.bambuModelByShortCode);
 }
+
+// model token compiles to a flexible-whitespace word-boundary regex.
+function _tokenToRegex(token: string): RegExp {
+  const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+');
+  return new RegExp(`\\b${escaped}\\b`, 'i');
+}
+
+// Extract printer model from a preset name → normalized short code
+// (e.g. "X1C", "H2D"). Two strategies in order:
+//
+// (1) ``@`` suffix — the BambuStudio naming convention. Two shapes:
+//   - "@BBL X1C 0.4 nozzle"               → "X1C"  (short-code form,
+//      Bambu Cloud system presets)
+//   - "@Bambu Lab X1 Carbon 0.4 nozzle"   → "X1C"  (long-form, used by
+//      user-renamed Bambu Cloud presets and most Orca Cloud profiles —
+//      reverse-looked-up via the backend printer-model registry)
+//
+// (2) Body scan — many user-authored / Orca Cloud presets put the printer
+// model at the START of the name with no @ suffix at all (the literal
+// shape that surfaced #1623: "X1C eSUN PETG-Basic Filament"). Scan the
+// name for any known model token (every long-name fragment + every short
+// code from the registry) and return the first match. Long-first sort
+// keeps "A1 Mini" / "X1 Carbon" / "H2D Pro" from being eaten by their
+// shorter sibling ("A1" / "X1" / "H2D"). Word-boundary regex prevents
+// false-positives on partial substrings (e.g. "PA1" doesn't match "A1",
+// "X1Box" doesn't match "X1").
+//
+// Returns null when neither strategy resolves; the caller keeps such
+// presets visible (can't filter what we can't classify).
+//
+// ``printerModelsLongToShort`` is the backend's PRINTER_MODEL_MAP shape:
+// keys are "Bambu Lab <long>", values are short codes.
+export function extractPresetModel(
+  name: string,
+  printerModelsLongToShort: Record<string, string>,
+): string | null {
+  const atIdx = name.indexOf('@');
+  if (atIdx >= 0) {
+    const suffix = name.slice(atIdx + 1).trim();
+    const bblMatch = suffix.match(/^BBL\s+(.+?)(?:\s+[\d.]+\s*nozzle)?$/i);
+    if (bblMatch) return bblMatch[1].trim();
+    const longMatch = suffix.match(/^Bambu Lab\s+(.+?)(?:\s+[\d.]+\s*nozzle)?$/i);
+    if (longMatch) {
+      const longFragment = longMatch[1].trim();
+      const fullKey = `Bambu Lab ${longFragment}`;
+      if (printerModelsLongToShort[fullKey]) return printerModelsLongToShort[fullKey];
+      const lower = fullKey.toLowerCase();
+      for (const [k, v] of Object.entries(printerModelsLongToShort)) {
+        if (k.toLowerCase() === lower) return v;
+      }
+      return longFragment;
+    }
+  }
+
+  // Body scan — accumulate {token, short} pairs and try long-first.
+  const tokens: Array<{ token: string; short: string }> = [];
+  const seen = new Set<string>();
+  for (const [longName, short] of Object.entries(printerModelsLongToShort)) {
+    const fragment = longName.replace(/^Bambu Lab\s+/, '');
+    const key = fragment.toLowerCase();
+    if (!seen.has(key)) {
+      tokens.push({ token: fragment, short });
+      seen.add(key);
+    }
+    const shortKey = short.toLowerCase();
+    if (!seen.has(shortKey)) {
+      tokens.push({ token: short, short });
+      seen.add(shortKey);
+    }
+  }
+  tokens.sort((a, b) => b.token.length - a.token.length);
+  for (const { token, short } of tokens) {
+    if (_tokenToRegex(token).test(name)) return short;
+  }
+  return null;
+}

Разница между файлами не показана из-за своего большого размера
+ 0 - 1
static/assets/index-BzJRM4M1.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-CLXSCni4.js


Разница между файлами не показана из-за своего большого размера
+ 1 - 0
static/assets/index-q2IPtdZB.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DFcvtgAy.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-BzJRM4M1.css">
+    <script type="module" crossorigin src="/assets/index-CLXSCni4.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-q2IPtdZB.css">
   </head>
   <body>
     <div id="root"></div>

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