Parcourir la source

Key a K profile on its nozzle's flow type

    A printer files each calibration under a nozzle id of the form HH00-0.4
    (high flow) or HS00-0.4 (standard) and can hold both for one diameter -- a
    maintainer's H2D carries 102 high-flow entries against 6 standard --
    because the same filament reads a different K through each. Nothing read
    that, so a standard-flow profile could be selected for a high-flow nozzle
    and vice versa.

    The flow is now stored with the profile, shown against each option in the
    picker, and checked before a stored profile is applied. Two spellings have
    to agree for that: a calibration entry says HH00-0.4 while the fitted
    nozzle reports HH01, so the comparison is two characters rather than four
    -- the trailing digits are a hardware variant the calibration table
    normalises to 00.

    Unknown flow on either side matches anything, which is what it has to do.
    Every profile stored before this has none. And an X1C declares none on any
    profile at all -- probed live, all eight come back with an empty nozzle id,
    against a four-digit cali_idx and a populated setting_id -- even though the
    machine really does take either nozzle. supports_nozzle_flow_type is
    therefore the wrong thing to gate on: it returns True for an X1C, and
    treating that silence as Standard would have dropped every X1C profile the
    moment a high-flow nozzle was fitted. What the printer's own table declares
    per profile is the test.

    NozzleInfo.nozzle_type carries two vocabularies by printer generation --
    the nozzle material on legacy printers, the flow code on H2 -- and the
    comment claiming only the former is corrected. Anything that is not HH or
    HS reads as unknown, which is what makes the material spelling harmless.

    Storing both flows for one hotend and diameter is deliberately not done:
    spoolman_k_profile is UNIQUE on (spool, printer, extruder, diameter) with
    no flow column, and allowing a second row in internal mode alone would
    break inventory-mode parity. The picker marks a profile whose flow does not
    match what is fitted instead of letting it look configured while doing
    nothing.
MartinNYHC il y a 1 semaine
Parent
commit
2253afbf45
33 fichiers modifiés avec 458 ajouts et 18 suppressions
  1. 6 0
      backend/app/api/routes/inventory.py
  2. 3 0
      backend/app/api/routes/printers.py
  3. 2 0
      backend/app/api/routes/spoolman.py
  4. 2 0
      backend/app/api/routes/spoolman_inventory.py
  5. 7 1
      backend/app/main.py
  6. 15 2
      backend/app/services/slot_kprofile.py
  7. 62 0
      backend/app/services/slot_nozzle.py
  8. 2 0
      backend/app/services/spool_tag_matcher.py
  9. 79 3
      backend/tests/unit/test_rfid_assign_picks_the_right_hotend.py
  10. 60 1
      backend/tests/unit/test_slot_nozzle_resolution.py
  11. 70 1
      frontend/src/__tests__/components/PrinterProfilesSection.test.tsx
  12. 11 2
      frontend/src/api/client.ts
  13. 9 0
      frontend/src/components/SpoolFormModal.tsx
  14. 52 6
      frontend/src/components/spool-form/PrinterProfilesSection.tsx
  15. 5 1
      frontend/src/components/spool-form/types.ts
  16. 1 0
      frontend/src/components/spool-form/utils.ts
  17. 1 0
      frontend/src/i18n/locales/de.ts
  18. 1 0
      frontend/src/i18n/locales/en.ts
  19. 1 0
      frontend/src/i18n/locales/es.ts
  20. 1 0
      frontend/src/i18n/locales/fr.ts
  21. 1 0
      frontend/src/i18n/locales/it.ts
  22. 1 0
      frontend/src/i18n/locales/ja.ts
  23. 1 0
      frontend/src/i18n/locales/ko.ts
  24. 1 0
      frontend/src/i18n/locales/nl.ts
  25. 1 0
      frontend/src/i18n/locales/pt-BR.ts
  26. 1 0
      frontend/src/i18n/locales/ru.ts
  27. 1 0
      frontend/src/i18n/locales/tr.ts
  28. 1 0
      frontend/src/i18n/locales/uk.ts
  29. 1 0
      frontend/src/i18n/locales/zh-CN.ts
  30. 1 0
      frontend/src/i18n/locales/zh-TW.ts
  31. 57 0
      frontend/src/utils/nozzleFlow.ts
  32. 0 0
      static/assets/index-CLXSCni4.js
  33. 1 1
      static/index.html

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

@@ -234,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

+ 3 - 0
backend/app/api/routes/printers.py

@@ -2694,6 +2694,7 @@ async def get_slot_spool_defaults(
         slot_nozzle.extruder_or_default,
         slot_nozzle.diameter,
         model,
+        slot_nozzle.flow,
     )
 
     slicer_filament: str | None = None
@@ -4335,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:

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

@@ -1033,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

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

@@ -1603,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

+ 7 - 1
backend/app/main.py

@@ -128,7 +128,11 @@ 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, resolve_slot_nozzle
+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,
@@ -1922,6 +1926,7 @@ async def on_fts_inlet_change(printer_id: int, ams_id: int, inlet: str):
                     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
@@ -2390,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 (

+ 15 - 2
backend/app/services/slot_kprofile.py

@@ -39,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,
@@ -47,6 +54,7 @@ async def find_slot_kprofile_for_extruder(
     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``.
 
@@ -91,8 +99,12 @@ 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
@@ -148,8 +160,9 @@ 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
 

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

@@ -64,12 +64,73 @@ class SlotNozzle:
     # 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.
@@ -114,4 +175,5 @@ def resolve_slot_nozzle(state, ams_id: int, tray_id: int, model: str | None = No
     return SlotNozzle(
         extruder=extruder,
         diameter=nozzle_diameter_for_extruder(state, extruder, model),
+        flow=nozzle_flow_for_extruder(state, extruder, model),
     )

+ 2 - 0
backend/app/services/spool_tag_matcher.py

@@ -584,6 +584,8 @@ async def auto_assign_spool(
             for kp in spool.k_profiles:
                 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

+ 79 - 3
backend/tests/unit/test_rfid_assign_picks_the_right_hotend.py

@@ -30,15 +30,16 @@ RIGHT, LEFT = 0, 1
 
 
 class _Nozzle:
-    def __init__(self, diameter):
+    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")):
-        self.nozzles = [_Nozzle(d) for d in diameters]
+    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 = {}
@@ -243,3 +244,78 @@ class TestFallbacks:
         # 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)

+ 60 - 1
backend/tests/unit/test_slot_nozzle_resolution.py

@@ -16,7 +16,10 @@ 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,
 )
 
@@ -24,11 +27,14 @@ from backend.app.services.slot_nozzle import (
 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):
+    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
 
@@ -118,3 +124,56 @@ class TestMatchingNozzles:
         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")

+ 70 - 1
frontend/src/__tests__/components/PrinterProfilesSection.test.tsx

@@ -54,6 +54,7 @@ function cal(overrides: Partial<CalibrationProfile>): CalibrationProfile {
     n_coef: 1.0,
     extruder_id: 0,
     nozzle_diameter: '0.4',
+    nozzle_id: '',
     ...overrides,
   };
 }
@@ -66,7 +67,7 @@ function printer(
     connected?: boolean;
     nozzleCount?: number;
     calibrations?: CalibrationProfile[];
-    nozzles?: { nozzle_diameter?: string }[];
+    nozzles?: { nozzle_diameter?: string; nozzle_type?: string }[];
   } = {},
 ): PrinterWithCalibrations {
   return {
@@ -559,6 +560,74 @@ describe('PrinterProfilesSection — K profiles', () => {
   });
 });
 
+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={[]} />);

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

+ 9 - 0
frontend/src/components/SpoolFormModal.tsx

@@ -22,6 +22,7 @@ import { ColorSection } from './spool-form/ColorSection';
 import { AdditionalSection } from './spool-form/AdditionalSection';
 import { SpoolmanFilamentPicker } from './spool-form/SpoolmanFilamentPicker';
 import { PrinterProfilesSection } from './spool-form/PrinterProfilesSection';
+import { normaliseFlow } from '../utils/nozzleFlow';
 import { SpoolUsageHistory } from './SpoolUsageHistory';
 import {
   invalidateInventoryLocations,
@@ -432,6 +433,9 @@ export function SpoolFormModal({
               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(chosen);
@@ -781,6 +785,11 @@ export function SpoolFormModal({
         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,

+ 52 - 6
frontend/src/components/spool-form/PrinterProfilesSection.tsx

@@ -11,6 +11,8 @@ 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
@@ -50,6 +52,20 @@ interface ModelGroup {
   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 ?? []) {
@@ -564,9 +580,26 @@ export function PrinterProfilesSection({
                                     </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 => {
@@ -575,14 +608,27 @@ export function PrinterProfilesSection({
                                         ?? null;
                                       chooseProfile(entry.printer.id, column.extruder, diameter, cal);
                                     }}
-                                    className="min-w-0 px-2 py-1.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-sm text-white focus:outline-none focus:border-bambu-green"
+                                    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 => (
-                                      <option key={cal.cali_idx} value={cal.cali_idx}>
-                                        {`${cal.name || cal.filament_id}  K=${cal.k_value.toFixed(3)}`}
-                                      </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>
                                 );
                               })}

+ 5 - 1
frontend/src/components/spool-form/types.ts

@@ -77,7 +77,7 @@ export interface PrinterWithCalibrations {
   // 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 }[];
+  nozzles?: { nozzle_diameter?: string; nozzle_type?: string }[];
 }
 
 // One spool's chosen preset for a printer model, as the Printers tab holds it
@@ -98,6 +98,10 @@ 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)`

+ 1 - 0
frontend/src/components/spool-form/utils.ts

@@ -56,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,
       });
     }
   }

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

@@ -4845,6 +4845,7 @@ export default {
     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',

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

@@ -4890,6 +4890,7 @@ export default {
     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',

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

@@ -4852,6 +4852,7 @@ export default {
     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',

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

@@ -4834,6 +4834,7 @@ export default {
     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',

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

@@ -4833,6 +4833,7 @@ export default {
     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',

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

@@ -4845,6 +4845,7 @@ export default {
     autoMatchPresetsHint: '各モデル名を含むスプールプリセットのバリアントを探します',
     kProfilesPerPrinter: 'Kプロファイル',
     kProfileNotSet: '未設定',
+    kProfileFlowMismatch: 'このプロファイルは{{profile}}ノズルで測定されましたが、装着されているのは{{fitted}}ノズルのため適用されません',
     nozzle: 'ノズル',
     unknownModel: '不明なモデル',
     onePrinter: 'プリンター1台',

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

@@ -4625,6 +4625,7 @@ export default {
     autoMatchPresetsHint: '각 모델 이름이 들어간 스풀 프리셋 변형을 찾습니다',
     kProfilesPerPrinter: 'K 프로파일',
     kProfileNotSet: '설정 안 됨',
+    kProfileFlowMismatch: '이 프로파일은 {{profile}} 노즐에서 측정되었지만 장착된 노즐은 {{fitted}}이므로 적용되지 않습니다',
     nozzle: '노즐',
     unknownModel: '알 수 없는 모델',
     onePrinter: '프린터 1대',

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

@@ -4890,6 +4890,7 @@ export default {
     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',

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

@@ -4833,6 +4833,7 @@ export default {
     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',

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

@@ -4616,6 +4616,7 @@ export default {
     autoMatchPresetsHint: 'Найти вариант пресета катушки, в названии которого указана каждая модель',
     kProfilesPerPrinter: 'K-профили',
     kProfileNotSet: 'Не задан',
+    kProfileFlowMismatch: 'Этот профиль измерен на сопле {{profile}}, но установлено сопло {{fitted}}, поэтому он не будет применён',
     nozzle: 'Сопло',
     unknownModel: 'Неизвестная модель',
     onePrinter: '1 принтер',

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

@@ -4828,6 +4828,7 @@ export default {
     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ı',

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

@@ -4887,6 +4887,7 @@ export default {
     autoMatchPresetsHint: 'Знайти варіант пресета котушки, у назві якого вказано кожну модель',
     kProfilesPerPrinter: 'K-профілі',
     kProfileNotSet: 'Не задано',
+    kProfileFlowMismatch: 'Цей профіль виміряно на соплі {{profile}}, але встановлено сопло {{fitted}}, тому його не буде застосовано',
     nozzle: 'Сопло',
     unknownModel: 'Невідома модель',
     onePrinter: '1 принтер',

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

@@ -4839,6 +4839,7 @@ export default {
     autoMatchPresetsHint: '查找料卷预设中标明各机型的对应版本',
     kProfilesPerPrinter: 'K 值配置',
     kProfileNotSet: '未设置',
+    kProfileFlowMismatch: '此配置是在 {{profile}} 喷嘴上测得的,但当前装的是 {{fitted}} 喷嘴,因此不会应用',
     nozzle: '喷嘴',
     unknownModel: '未知机型',
     onePrinter: '1 台打印机',

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

@@ -4839,6 +4839,7 @@ export default {
     autoMatchPresetsHint: '尋找線材捲預設中標示各機型的對應版本',
     kProfilesPerPrinter: 'K 值設定檔',
     kProfileNotSet: '未設定',
+    kProfileFlowMismatch: '此設定檔是在 {{profile}} 噴嘴上測得的,但目前裝的是 {{fitted}} 噴嘴,因此不會套用',
     nozzle: '噴嘴',
     unknownModel: '未知機型',
     onePrinter: '1 台印表機',

+ 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;
+}

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/index-CLXSCni4.js


+ 1 - 1
static/index.html

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

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff