Jelajahi Sumber

File the printer's calibration table under the nozzle it belongs to (issue #2854)

    The K value on an AMS slot card went blank after a while and came back after a
    backend restart. It is not the MQTT merge: that preserves a tray's k correctly.
    H2-series trays have no k to preserve. Verified against the H2 wire capture in
    logs/vp_wire -- every tray reports cali_idx and nothing else -- so the number on
    the card is resolved from that index against the printer's calibration table in
    state.kprofiles, and that table was a single global list.

    An extrusion_cali_get response is the complete table for one nozzle diameter,
    and the printer answers whoever asks; BambuStudio's queries arrive on the same
    report topic we subscribe to. Every response was assigned straight to
    state.kprofiles, so any one answer stood for the whole printer. The nightly
    GitHub backup asks for 0.2, 0.4, 0.6 and 0.8 in turn and finishes on 0.8, which
    holds nothing on a 0.4+0.6 machine: logs/bambuddy.log records exactly that at
    17:15 on 2026-08-25, and the table was empty from then until something refilled
    it. Responses are now bucketed by the diameter they describe, so an empty answer
    for a size the printer does not have clears only that size. The three assign
    paths that look an index up by nozzle_diameter get the same fix for free -- they
    were quietly finding nothing whenever the last response was for another nozzle,
    which is what spoolman_inventory has been logging as a stale kp.

    Bucketing makes state.kprofiles a union, and cali_idx is numbered per nozzle, so
    the index alone no longer identifies a profile. The REST serializer has keyed on
    (extruder, cali_idx) since c5e005586; the WebSocket one still keyed on the index
    alone, which meant the first render of a card could be right and every update
    after it wrong. Both now share one resolver. It goes through the extruder the
    slot feeds, and where that does not single out one profile -- a single-nozzle
    printer that has been swapped, so both its tables sit under extruder 0 -- it
    falls back to which diameters are actually fitted. Where neither settles it the
    card shows nothing, because a blank space is a smaller error than confidently
    printing the other nozzle's number. Deliberately no loosening to a bare cali_idx
    lookup on a miss: that is the cross-nozzle bleed the extruder keying was added
    to stop.

    Nothing read the table on connect, which is the other half of the report. It
    arrived by luck -- a visit to Profiles or Configure Slot, a backup, or the
    printer answering someone else -- so a Bambuddy nobody had opened showed a card
    with no K values at all, and "restart and they come back" was the printer
    happening to broadcast rather than anything we did. It is now read once per
    connection, on the same latch the stale-print reconcile uses. Only the fitted
    diameters are asked for, one request on a single-nozzle printer and two on a
    dual; probing the four sizes blind is what the backup does and what blanked the
    table. The edge is gated on a nozzle diameter being known as well as on the
    state being known, because the first push_status is what makes the state known
    and does not always carry the nozzle fields -- latching there would spend the
    connection's one attempt on a printer that could not yet say what was fitted.

    Adopting an unsolicited table now logs at debug. It was the quietest way for the
    card to change underneath us and there was no way to see it in a support bundle.

    Three test files gained nozzles=[] on their PrinterState stubs. The field has
    always been on the dataclass; the connect edge is simply the first thing on that
    path to read it.
maziggy 1 Minggu lalu
induk
melakukan
eee94ce9c8

+ 8 - 24
backend/app/api/routes/printers.py

@@ -90,6 +90,7 @@ 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
 from backend.app.utils.http import build_content_disposition, download_error_response, safe_download_filename
+from backend.app.utils.kprofile_lookup import build_slot_k_resolver
 from backend.app.utils.printer_models import MAX_CHAMBER_TEMP_C, uses_exhaust_fan_label
 
 logger = logging.getLogger(__name__)
@@ -523,31 +524,14 @@ async def get_printer_status(
     ams_exists = False
     raw_data = state.raw_data or {}
 
-    # Build K-profile lookup map: (extruder_id, cali_idx) -> k_value.
+    # K value for a slot's bound profile, resolved against its own nozzle.
     #
-    # Keyed on the pair, not on cali_idx alone: the printer numbers its
-    # calibration table per nozzle, so entry 16 exists on both and means a
-    # different profile on each. A cali_idx-only map let whichever profile the
-    # printer happened to list last overwrite the other, and the slot then
-    # displayed the wrong nozzle's K — on the maintainer's H2C, 0.018 and 0.020
-    # for the same spool.
-    kprofile_map: dict[tuple[int, int], float] = {}
-    for kp in state.kprofiles or []:
-        if kp.slot_id is not None and kp.k_value:
-            try:
-                kprofile_map[(int(kp.extruder_id or 0), kp.slot_id)] = float(kp.k_value)
-            except (ValueError, TypeError):
-                pass  # Skip K-profile entries with unparseable values
-
-    def _kprofile_k(cali_idx: int | None, ams_id: int, tray_id: int) -> float | None:
-        """K value for a slot's bound profile, resolved against its own nozzle."""
-        if cali_idx is None:
-            return None
-        extruder = slot_extruder(ams_id, tray_id, state.ams_extruder_map, state.ams_switch_inlet)
-        if extruder is not None:
-            return kprofile_map.get((extruder, cali_idx))
-        # Single-nozzle printers report everything under extruder 0.
-        return kprofile_map.get((0, cali_idx))
+    # Keyed on more than cali_idx: the printer numbers its calibration table
+    # per nozzle, so entry 16 exists on each and means a different profile on
+    # each. A cali_idx-only map let whichever profile the printer happened to
+    # list last overwrite the other, and the slot then displayed the wrong
+    # nozzle's K — on the maintainer's H2C, 0.018 and 0.020 for the same spool.
+    _kprofile_k = build_slot_k_resolver(state)
 
     # Cached active-cycle drying params (filament + target temp) we sent
     # last; Bambu doesn't echo them on the per-tick AMS push, so the badge

+ 83 - 0
backend/app/main.py

@@ -416,6 +416,15 @@ _INPRINT_BANK_MIN_INTERVAL = 25.0
 # reconnect re-arms reconciliation. Keyed by printer_id.
 _printer_reconciled_since_connect: dict[int, bool] = {}
 
+# Same edge, same keying, for priming the printer's calibration table exactly
+# once per (re)connection. Nothing else asks for it on connect: state.kprofiles
+# is otherwise filled only when someone opens the Profiles page or Configure
+# Slot, when a GitHub backup runs, or when the printer happens to answer
+# somebody else's query on the report topic. Until then the AMS slot card has
+# no K value to show on the printers whose trays carry none of their own
+# (#2854 — H2-series report cali_idx and nothing more).
+_printer_kprofiles_primed_since_connect: dict[int, bool] = {}
+
 # Track expected prints from reprint/scheduled (skip auto-archiving for these)
 # {(printer_id, filename): archive_id}
 _expected_prints: dict[tuple[int, str], int] = {}
@@ -1420,6 +1429,28 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
         # Re-arm so the next reconnect triggers reconciliation again.
         _printer_reconciled_since_connect[printer_id] = False
 
+    # Same edge, for the calibration table the AMS card reads its K values from.
+    #
+    # Also gated on knowing a nozzle diameter, which is what decides *which*
+    # tables to ask for. A `state_known` gate alone is not enough: the first
+    # real push_status is what makes the state known, and the nozzle fields do
+    # not always arrive in it. Latching there would spend this connection's one
+    # attempt on a printer that could not yet say what was fitted.
+    nozzle_known = any(n.nozzle_diameter for n in (state.nozzles or []))
+    if (
+        state.connected
+        and state_known
+        and nozzle_known
+        and not _printer_kprofiles_primed_since_connect.get(printer_id, False)
+    ):
+        _printer_kprofiles_primed_since_connect[printer_id] = True
+        spawn_background_task(
+            prime_kprofile_table(printer_id),
+            name=f"prime-kprofiles-{printer_id}",
+        )
+    elif not state.connected and _printer_kprofiles_primed_since_connect.get(printer_id, False):
+        _printer_kprofiles_primed_since_connect[printer_id] = False
+
     # Offline-notification edge (#1752): schedule `on_printer_offline` on
     # connected → disconnected. The "back online" channel is already covered
     # by the print-failure notification (firmware reports gcode_state=FAILED
@@ -5274,6 +5305,58 @@ def _is_active_archive_stale(archive, state) -> tuple[bool, str]:
     return False, ""
 
 
+async def prime_kprofile_table(printer_id: int) -> int:
+    """Read the printer's calibration table once per connection.
+
+    The AMS slot card shows a K value per slot (#2854). On the printers whose
+    trays carry no ``k`` field of their own -- the whole H2 series, whose trays
+    report ``cali_idx`` and nothing else -- that number can only come from
+    ``state.kprofiles``, and nothing used to fill it on connect. It arrived by
+    luck: someone opening the Profiles page or Configure Slot, a nightly GitHub
+    backup, or the printer answering a query BambuStudio made on the report
+    topic we share. A Bambuddy that nobody visited showed a card with no K
+    values at all.
+
+    Only the diameters actually fitted are asked for, which is one request on a
+    single-nozzle printer and two on a dual. Probing the four sizes blind is
+    what the backup does, and it is both wasteful and the thing that used to
+    blank the table.
+
+    Returns the number of nozzles whose table was read.
+    """
+    client = printer_manager.get_client(printer_id)
+    state = printer_manager.get_status(printer_id)
+    if client is None or state is None or not state.connected:
+        return 0
+
+    # Deduplicated, order preserved: a dual-nozzle printer with two 0.4s should
+    # ask once, and both entries are empty until the first push_status lands.
+    diameters = list(dict.fromkeys(n.nozzle_diameter for n in (state.nozzles or []) if n.nozzle_diameter))
+    if not diameters:
+        logging.getLogger(__name__).debug(
+            "[Printer %s] No nozzle diameter reported yet; leaving the K-profile table to the next reader",
+            printer_id,
+        )
+        return 0
+
+    primed = 0
+    for diameter in diameters:
+        try:
+            profiles = await client.get_kprofiles(nozzle_diameter=diameter, max_retries=2)
+        except Exception as exc:  # noqa: BLE001
+            # A printer that won't answer costs the card its K values, nothing
+            # more — never the connection this runs on the back of.
+            logging.getLogger(__name__).warning(
+                "[Printer %s] Could not read the K-profile table for nozzle %s: %s", printer_id, diameter, exc
+            )
+            continue
+        primed += 1
+        logging.getLogger(__name__).info(
+            "[Printer %s] Primed K-profile table for nozzle %s: %d profiles", printer_id, diameter, len(profiles)
+        )
+    return primed
+
+
 async def reconcile_stale_active_prints(printer_id: int) -> int:
     """Synthesise ``on_print_complete`` for archives whose print can't be
     running on the printer anymore.

+ 56 - 2
backend/app/services/bambu_mqtt.py

@@ -1279,6 +1279,24 @@ class BambuMQTTClient:
         # Value: {"nozzle": str, "event": asyncio.Event, "profiles": list | None}.
         self._sequence_id: int = 0
         self._pending_kprofile_requests: dict[str, dict] = {}
+        # The printer's calibration table, one bucket per nozzle diameter.
+        #
+        # An extrusion_cali_get response is the complete table for *one* nozzle
+        # size, and the printer answers whoever asks — including BambuStudio,
+        # whose queries land on the same report topic we subscribe to. Assigning
+        # each response straight to state.kprofiles therefore let any single
+        # answer stand for the whole printer: a GitHub backup probing
+        # 0.2/0.4/0.6/0.8 in turn finished on 0.8, which holds no profiles on a
+        # 0.4+0.6 machine, and left the list empty until something refilled it.
+        # Measured on the maintainer's H2 on 2026-08-25, and visible on the AMS
+        # card because H2-series trays carry no `k` of their own — the slot's
+        # K value is resolved from cali_idx against exactly this list.
+        #
+        # Keyed by diameter so a response only ever replaces the bucket it
+        # actually describes; state.kprofiles is then the union across buckets.
+        # An empty answer for a nozzle the printer doesn't have empties that
+        # bucket alone.
+        self._kprofiles_by_nozzle: dict[str, list] = {}
         # Acks for K-profile *writes* (extrusion_cali_set / extrusion_cali_del),
         # keyed by the sequence_id we sent. The printer echoes it back, measured
         # on both an X1C and an H2D (#2718). Filled by the MQTT thread, drained
@@ -6422,6 +6440,33 @@ class BambuMQTTClient:
                     logger.debug("Failed to parse K-profile from broadcast: %s", e)
         return profiles
 
+    def _store_kprofiles(self, profiles: list, response_nozzle: str | None) -> None:
+        """File one calibration-table response under its nozzle diameter.
+
+        ``response_nozzle`` names the table the printer just sent, so that
+        bucket is replaced wholesale and every other one is left alone. When
+        the envelope carries no diameter, fall back to the diameters the parsed
+        profiles claim for themselves — and if there are none of those either,
+        keep what we have rather than dropping a table we cannot attribute.
+
+        ``state.kprofiles`` stays a flat list because that is what its readers
+        expect; the three assign paths already filter it by ``nozzle_diameter``
+        and were quietly finding nothing whenever the last response happened to
+        be for a different nozzle.
+        """
+        buckets: dict[str, list] = {}
+        if response_nozzle:
+            buckets[str(response_nozzle)] = list(profiles)
+        else:
+            for profile in profiles:
+                buckets.setdefault(str(profile.nozzle_diameter), []).append(profile)
+        if not buckets:
+            return
+        self._kprofiles_by_nozzle.update(buckets)
+        self.state.kprofiles = [
+            kp for nozzle in sorted(self._kprofiles_by_nozzle) for kp in self._kprofiles_by_nozzle[nozzle]
+        ]
+
     def _handle_kprofile_response(self, data: dict):
         """Handle K-profile response from printer."""
         response_nozzle = data.get("nozzle_diameter")
@@ -6469,11 +6514,20 @@ class BambuMQTTClient:
             return
 
         profiles = self._parse_kprofile_entries(filaments, response_nozzle, log_errors=request is not None)
-        self.state.kprofiles = profiles
+        self._store_kprofiles(profiles, response_nozzle)
 
         if request is None:
             # Unsolicited broadcast with nothing in flight: state is refreshed,
-            # nobody to wake.
+            # nobody to wake. Worth a line — this is the printer answering
+            # somebody else (BambuStudio queries the same report topic), and
+            # until it was bucketed by nozzle it was also the quietest way for
+            # the AMS card's K values to change underneath us.
+            logger.debug(
+                "[%s] Adopted unsolicited K-profile table: nozzle=%s, %d profiles",
+                self.serial_number,
+                response_nozzle or "?",
+                len(profiles),
+            )
             return
 
         logger.info("[%s] Got %s K-profiles for nozzle=%s", self.serial_number, len(profiles), response_nozzle)

+ 13 - 12
backend/app/services/printer_manager.py

@@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.models.printer import Printer
 from backend.app.services.bambu_mqtt import BambuMQTTClient, MQTTLogEntry, PrinterState, get_stage_name
+from backend.app.utils.kprofile_lookup import build_slot_k_resolver
 
 logger = logging.getLogger(__name__)
 
@@ -1330,14 +1331,11 @@ def printer_state_to_dict(
     vt_tray = []
     raw_data = state.raw_data or {}
 
-    # Build K-profile lookup map: cali_idx -> k_value
-    kprofile_map: dict[int, float] = {}
-    for kp in state.kprofiles or []:
-        if kp.slot_id is not None and kp.k_value:
-            try:
-                kprofile_map[kp.slot_id] = float(kp.k_value)
-            except (ValueError, TypeError):
-                pass  # Skip K-profile entries with unparseable values
+    # K value for a slot's bound profile. Shared with the REST serializer of
+    # the same card (routes/printers.py) so the two cannot answer differently:
+    # this one used to key on cali_idx alone, which on a dual-nozzle machine
+    # meant whichever nozzle's table was listed last won the slot.
+    resolve_slot_k = build_slot_k_resolver(state)
 
     if "ams" in raw_data and isinstance(raw_data["ams"], list):
         for ams_data in raw_data["ams"]:
@@ -1353,8 +1351,8 @@ def printer_state_to_dict(
                 # Get K value: first try tray's k field, then lookup from K-profiles
                 k_value = tray.get("k")
                 cali_idx = tray.get("cali_idx")
-                if k_value is None and cali_idx is not None and cali_idx in kprofile_map:
-                    k_value = kprofile_map[cali_idx]
+                if k_value is None:
+                    k_value = resolve_slot_k(cali_idx, int(ams_data.get("id", 0)), int(tray.get("id", 0)))
 
                 # P1S / A1 Mini physically-empty-slot signal (#1322 follow-up by
                 # @RosdasHH): for a truly empty slot the firmware sends only
@@ -1486,8 +1484,11 @@ def printer_state_to_dict(
             # Get K value for vt_tray
             vt_k_value = vt_data.get("k")
             vt_cali_idx = vt_data.get("cali_idx")
-            if vt_k_value is None and vt_cali_idx is not None and vt_cali_idx in kprofile_map:
-                vt_k_value = kprofile_map[vt_cali_idx]
+            if vt_k_value is None:
+                # External holder: id 254 is Ext-L, 255 is Ext-R. The resolver
+                # takes the 0/1 tray index, so normalise before asking.
+                vt_id = int(vt_data.get("id", 254))
+                vt_k_value = resolve_slot_k(vt_cali_idx, 255, vt_id - 254 if vt_id >= 254 else vt_id)
 
             tray_id = int(vt_data.get("id", 254))
             vt_tray.append(

+ 66 - 0
backend/app/utils/kprofile_lookup.py

@@ -0,0 +1,66 @@
+"""Resolve an AMS slot's K value from the printer's calibration table.
+
+H2-series trays carry no ``k`` field of their own — only ``cali_idx`` — so the
+K value on the AMS slot card (#2854) is looked up from the printer's
+calibration table in ``state.kprofiles``. That table is not flat: the printer
+numbers it **per nozzle**, so entry 16 exists under every nozzle it holds
+profiles for and means a different profile on each.
+
+``state.kprofiles`` is the union across nozzle diameters (see
+``BambuMQTTClient._store_kprofiles``), which is what the assign paths need but
+makes ``cali_idx`` alone ambiguous. Resolution here is therefore:
+
+1. the slot's own extruder, which separates the two nozzles of a dual-nozzle
+   machine outright;
+2. failing that, the diameters currently installed, which separates a live
+   table from one left behind by a nozzle that has since been swapped out.
+
+If both fail to single out one profile the answer is ``None``. A blank space on
+the card is a smaller error than confidently printing the other nozzle's number.
+"""
+
+from collections.abc import Callable
+
+from backend.app.utils.fts_routing import slot_extruder
+
+
+def build_slot_k_resolver(state) -> Callable[[int | None, int, int], float | None]:
+    """Return ``resolve(cali_idx, ams_id, tray_id) -> k value or None``.
+
+    Built once per serialization pass and closed over the state, so the REST
+    and WebSocket views of the same card cannot answer differently.
+    """
+    # (extruder, cali_idx) -> {nozzle_diameter: k}. The inner dict is what
+    # detects the ambiguity: more than one entry means two nozzles' tables both
+    # claim this index on this extruder.
+    table: dict[tuple[int, int], dict[str, float]] = {}
+    for kp in getattr(state, "kprofiles", None) or []:
+        if kp.slot_id is None or not kp.k_value:
+            continue
+        try:
+            k_value = float(kp.k_value)
+        except (ValueError, TypeError):
+            continue  # Skip K-profile entries with unparseable values
+        try:
+            extruder = int(kp.extruder_id or 0)
+        except (ValueError, TypeError):
+            extruder = 0
+        table.setdefault((extruder, kp.slot_id), {})[str(kp.nozzle_diameter or "")] = k_value
+
+    installed = {str(n.nozzle_diameter) for n in (getattr(state, "nozzles", None) or []) if n.nozzle_diameter}
+
+    def resolve(cali_idx: int | None, ams_id: int, tray_id: int) -> float | None:
+        if cali_idx is None:
+            return None
+        extruder = slot_extruder(ams_id, tray_id, state.ams_extruder_map, state.ams_switch_inlet)
+        # Single-nozzle printers report everything under extruder 0, and that
+        # is also the right default when the routing is simply unknown.
+        by_nozzle = table.get((extruder if extruder is not None else 0, cali_idx))
+        if not by_nozzle:
+            return None
+        if len(by_nozzle) == 1:
+            return next(iter(by_nozzle.values()))
+        live = [k for nozzle, k in by_nozzle.items() if nozzle in installed]
+        return live[0] if len(live) == 1 else None
+
+    return resolve

+ 350 - 0
backend/tests/unit/services/test_kprofile_nozzle_buckets_2854.py

@@ -0,0 +1,350 @@
+"""The slot-card K value survives a query for a nozzle the printer lacks.
+
+H2-series AMS trays carry no ``k`` field -- verified against a live H2 wire
+capture, where every tray reports ``cali_idx`` and nothing else -- so the value
+PR #2854 put on the slot card is resolved from the printer's calibration table
+in ``state.kprofiles``.
+
+That table is answered per nozzle diameter, and the printer answers whoever
+asks: BambuStudio's queries land on the same report topic Bambuddy subscribes
+to. Assigning each response straight to ``state.kprofiles`` let one answer
+stand for the whole printer. Measured on the maintainer's H2 on 2026-08-25:
+the nightly GitHub backup probes 0.2/0.4/0.6/0.8 in turn, the 0.8 probe found
+no profiles on a 0.4+0.6 machine, and every K value on the card went blank
+until something refilled the list.
+"""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services.bambu_mqtt import BambuMQTTClient, KProfile, NozzleInfo, PrinterState
+from backend.app.utils.kprofile_lookup import build_slot_k_resolver
+
+
+def _client() -> BambuMQTTClient:
+    """A client with no transport -- only the response handling is under test."""
+    return BambuMQTTClient(ip_address="10.0.0.1", serial_number="TESTSERIAL0000", access_code="00000000")
+
+
+def _response(nozzle: str, *entries: tuple[int, str]) -> dict:
+    """One ``extrusion_cali_get`` payload, as the printer sends it.
+
+    The envelope carries the nozzle diameter; the per-filament entries do not.
+    """
+    return {
+        "command": "extrusion_cali_get",
+        "nozzle_diameter": nozzle,
+        "filaments": [
+            {
+                "cali_idx": cali_idx,
+                "extruder_id": 0,
+                "filament_id": "GFL99",
+                "k_value": k_value,
+                "name": f"Profile {cali_idx}",
+                "setting_id": "GFSL99",
+            }
+            for cali_idx, k_value in entries
+        ],
+    }
+
+
+class TestNozzleBuckets:
+    def test_an_empty_table_clears_only_its_own_nozzle(self):
+        """The exact backup sequence that emptied the maintainer's card.
+
+        0.2 and 0.8 come back empty on a 0.4+0.6 machine. Neither may take the
+        other two nozzles' profiles with it.
+        """
+        client = _client()
+        client._handle_kprofile_response(_response("0.2"))
+        client._handle_kprofile_response(_response("0.4", (3, "0.020000")))
+        client._handle_kprofile_response(_response("0.6", (3, "0.018000")))
+        client._handle_kprofile_response(_response("0.8"))
+
+        by_nozzle = {kp.nozzle_diameter: kp.k_value for kp in client.state.kprofiles}
+        assert by_nozzle == {"0.4": "0.020000", "0.6": "0.018000"}
+
+    def test_a_fresh_table_replaces_its_own_nozzle_wholesale(self):
+        """A re-read is authoritative for its nozzle: deletions must stick."""
+        client = _client()
+        client._handle_kprofile_response(_response("0.4", (3, "0.020000"), (4, "0.021000")))
+        client._handle_kprofile_response(_response("0.4", (3, "0.019000")))
+
+        assert [(kp.slot_id, kp.k_value) for kp in client.state.kprofiles] == [(3, "0.019000")]
+
+    def test_a_response_for_one_nozzle_leaves_the_others_alone(self):
+        """The BambuStudio case: someone else asks about a nozzle we didn't."""
+        client = _client()
+        client._handle_kprofile_response(_response("0.4", (3, "0.020000")))
+        client._handle_kprofile_response(_response("0.6", (3, "0.018000")))
+
+        assert len(client.state.kprofiles) == 2
+
+    def test_an_unattributable_answer_is_not_allowed_to_empty_the_table(self):
+        """No envelope diameter and no entries names no bucket. Keep what we have."""
+        client = _client()
+        client._handle_kprofile_response(_response("0.4", (3, "0.020000")))
+        client._handle_kprofile_response({"command": "extrusion_cali_get", "filaments": []})
+
+        assert len(client.state.kprofiles) == 1
+
+    def test_entries_name_their_own_bucket_when_the_envelope_does_not(self):
+        """Firmware that omits the envelope diameter still has to be filed."""
+        client = _client()
+        client._handle_kprofile_response(_response("0.4", (3, "0.020000")))
+        client._handle_kprofile_response(
+            {
+                "command": "extrusion_cali_get",
+                "filaments": [
+                    {"cali_idx": 3, "extruder_id": 0, "k_value": "0.017000", "nozzle_diameter": "0.6"},
+                ],
+            }
+        )
+
+        by_nozzle = {kp.nozzle_diameter: kp.k_value for kp in client.state.kprofiles}
+        assert by_nozzle == {"0.4": "0.020000", "0.6": "0.017000"}
+
+    def test_a_pending_request_still_refuses_another_nozzles_answer(self):
+        """#1748's guard is unchanged: don't wake a waiter with the wrong table."""
+        client = _client()
+        client._pending_kprofile_requests["7"] = {"nozzle": "0.4", "event": MagicMock(), "profiles": None}
+        client._handle_kprofile_response(_response("0.6", (3, "0.018000")))
+
+        assert client.state.kprofiles == []
+
+
+def _state(profiles, *, nozzles=("0.4",), ams_extruder_map=None):
+    return SimpleNamespace(
+        kprofiles=list(profiles),
+        nozzles=[SimpleNamespace(nozzle_diameter=d) for d in nozzles],
+        ams_extruder_map=ams_extruder_map,
+        ams_switch_inlet=None,
+    )
+
+
+def _profile(cali_idx: int, k_value: str, nozzle: str, extruder: int = 0) -> KProfile:
+    return KProfile(
+        slot_id=cali_idx,
+        extruder_id=extruder,
+        nozzle_id="",
+        nozzle_diameter=nozzle,
+        filament_id="GFL99",
+        name=f"Profile {cali_idx}",
+        k_value=k_value,
+        n_coef="1.000000",
+        ams_id=0,
+        tray_id=-1,
+    )
+
+
+class TestSlotKResolver:
+    def test_a_slot_reads_the_profile_on_its_own_extruder(self):
+        """The H2C case from #2854: one spool, 0.018 left and 0.020 right.
+
+        AMS 0 feeds extruder 1, AMS 1 feeds extruder 0, and calibration index 3
+        exists on both.
+        """
+        resolve = build_slot_k_resolver(
+            _state(
+                [_profile(3, "0.020000", "0.4", extruder=0), _profile(3, "0.018000", "0.6", extruder=1)],
+                nozzles=("0.4", "0.6"),
+                ams_extruder_map={"0": 1, "1": 0},
+            )
+        )
+
+        assert resolve(3, 0, 0) == pytest.approx(0.018)
+        assert resolve(3, 1, 0) == pytest.approx(0.020)
+
+    def test_a_swapped_out_nozzles_stale_table_loses_to_the_installed_one(self):
+        """One extruder, two diameters: only one of them is fitted right now."""
+        resolve = build_slot_k_resolver(
+            _state([_profile(3, "0.020000", "0.4"), _profile(3, "0.017000", "0.6")], nozzles=("0.6",))
+        )
+
+        assert resolve(3, 0, 0) == pytest.approx(0.017)
+
+    def test_an_index_that_two_installed_nozzles_both_claim_reads_as_unknown(self):
+        """Blank beats confidently printing the other nozzle's number."""
+        resolve = build_slot_k_resolver(
+            _state([_profile(3, "0.020000", "0.4"), _profile(3, "0.017000", "0.6")], nozzles=("0.4", "0.6"))
+        )
+
+        assert resolve(3, 0, 0) is None
+
+    def test_a_single_nozzle_printer_resolves_without_an_extruder_map(self):
+        resolve = build_slot_k_resolver(_state([_profile(3, "0.020000", "0.4")]))
+
+        assert resolve(3, 0, 2) == pytest.approx(0.020)
+
+    def test_an_uncalibrated_slot_has_no_value(self):
+        resolve = build_slot_k_resolver(_state([_profile(3, "0.020000", "0.4")]))
+
+        assert resolve(None, 0, 0) is None
+        assert resolve(-1, 0, 0) is None
+
+    def test_an_unparseable_k_value_is_skipped_rather_than_raising(self):
+        resolve = build_slot_k_resolver(_state([_profile(3, "not-a-number", "0.4")]))
+
+        assert resolve(3, 0, 0) is None
+
+
+class TestPrimeKProfileTable:
+    """Nothing used to read the calibration table on connect.
+
+    ``state.kprofiles`` was filled only when someone opened the Profiles page
+    or Configure Slot, when a GitHub backup ran, or when the printer answered
+    a query BambuStudio made on the report topic Bambuddy shares. On the
+    printers whose trays carry no ``k``, a Bambuddy nobody had visited showed
+    an AMS card with no K values at all.
+    """
+
+    def _printer_state(self, *, nozzles, connected=True):
+        return SimpleNamespace(
+            connected=connected,
+            nozzles=[SimpleNamespace(nozzle_diameter=d) for d in nozzles],
+        )
+
+    async def _prime(self, printer_state, client):
+        from backend.app import main as main_module
+
+        with (
+            patch.object(main_module.printer_manager, "get_client", return_value=client),
+            patch.object(main_module.printer_manager, "get_status", return_value=printer_state),
+        ):
+            return await main_module.prime_kprofile_table(7)
+
+    @pytest.mark.asyncio
+    async def test_it_asks_for_every_fitted_nozzle(self):
+        """A dual-nozzle H2 needs both tables: cali_idx is numbered per nozzle."""
+        client = MagicMock()
+        client.get_kprofiles = AsyncMock(return_value=[])
+
+        primed = await self._prime(self._printer_state(nozzles=("0.4", "0.6")), client)
+
+        assert primed == 2
+        assert [call.kwargs["nozzle_diameter"] for call in client.get_kprofiles.await_args_list] == ["0.4", "0.6"]
+
+    @pytest.mark.asyncio
+    async def test_two_identical_nozzles_are_asked_for_once(self):
+        client = MagicMock()
+        client.get_kprofiles = AsyncMock(return_value=[])
+
+        primed = await self._prime(self._printer_state(nozzles=("0.4", "0.4")), client)
+
+        assert primed == 1
+
+    @pytest.mark.asyncio
+    async def test_it_never_probes_sizes_the_printer_does_not_have(self):
+        """Blind 0.2/0.4/0.6/0.8 probing is what the backup does, and it is
+        exactly what used to blank the table."""
+        client = MagicMock()
+        client.get_kprofiles = AsyncMock(return_value=[])
+
+        await self._prime(self._printer_state(nozzles=("0.6",)), client)
+
+        assert [call.kwargs["nozzle_diameter"] for call in client.get_kprofiles.await_args_list] == ["0.6"]
+
+    @pytest.mark.asyncio
+    async def test_no_reported_nozzle_yet_asks_nothing(self):
+        client = MagicMock()
+        client.get_kprofiles = AsyncMock(return_value=[])
+
+        primed = await self._prime(self._printer_state(nozzles=("",)), client)
+
+        assert primed == 0
+        client.get_kprofiles.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_a_disconnected_printer_is_left_alone(self):
+        client = MagicMock()
+        client.get_kprofiles = AsyncMock(return_value=[])
+
+        primed = await self._prime(self._printer_state(nozzles=("0.4",), connected=False), client)
+
+        assert primed == 0
+        client.get_kprofiles.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_one_nozzle_failing_does_not_cost_the_other_its_table(self):
+        """This runs on the back of a connection; it may not raise into it."""
+        client = MagicMock()
+        client.get_kprofiles = AsyncMock(side_effect=[TimeoutError("no answer"), []])
+
+        primed = await self._prime(self._printer_state(nozzles=("0.4", "0.6")), client)
+
+        assert primed == 1
+
+
+class TestPrimeOnConnectEdge:
+    """The connection's one priming attempt must not be spent too early."""
+
+    def _state(self, *, connected=True, state="IDLE", nozzles=("0.4",)):
+        """A real PrinterState: the handler reads far more of it than this
+        test cares about, and a stub would only pin the fields I remembered."""
+        printer_state = PrinterState()
+        printer_state.connected = connected
+        printer_state.state = state
+        printer_state.nozzles = [NozzleInfo(nozzle_diameter=d) for d in nozzles]
+        return printer_state
+
+    async def _edge(self, printer_state, main_module):
+        with (
+            patch.object(main_module, "spawn_background_task", side_effect=lambda coro, **kw: coro.close()) as spawn,
+            patch.object(main_module.ws_manager, "send_printer_status", new=AsyncMock()),
+            patch.object(main_module, "printer_state_to_dict", return_value={}),
+            patch.object(main_module.printer_manager, "get_model", return_value="H2D"),
+            patch.object(main_module.printer_manager, "get_drying_targets", return_value={}),
+        ):
+            await main_module.on_printer_status_change(31, printer_state)
+        return [call.kwargs.get("name", "") for call in spawn.call_args_list]
+
+    @pytest.fixture(autouse=True)
+    def _clean_latches(self):
+        from backend.app import main as main_module
+
+        main_module._printer_kprofiles_primed_since_connect.pop(31, None)
+        main_module._printer_reconciled_since_connect.pop(31, None)
+        yield
+        main_module._printer_kprofiles_primed_since_connect.pop(31, None)
+        main_module._printer_reconciled_since_connect.pop(31, None)
+
+    @pytest.mark.asyncio
+    async def test_a_connected_printer_with_a_known_nozzle_is_primed(self):
+        from backend.app import main as main_module
+
+        names = await self._edge(self._state(), main_module)
+
+        assert any(name.startswith("prime-kprofiles") for name in names)
+
+    @pytest.mark.asyncio
+    async def test_it_is_primed_once_per_connection(self):
+        from backend.app import main as main_module
+
+        await self._edge(self._state(), main_module)
+        names = await self._edge(self._state(), main_module)
+
+        assert not any(name.startswith("prime-kprofiles") for name in names)
+
+    @pytest.mark.asyncio
+    async def test_a_state_that_names_no_nozzle_yet_does_not_spend_the_attempt(self):
+        """The first push_status makes the state known but need not carry the
+        nozzle fields. Latching there would leave the table unread all session."""
+        from backend.app import main as main_module
+
+        early = await self._edge(self._state(nozzles=("",)), main_module)
+        assert not any(name.startswith("prime-kprofiles") for name in early)
+
+        later = await self._edge(self._state(), main_module)
+        assert any(name.startswith("prime-kprofiles") for name in later)
+
+    @pytest.mark.asyncio
+    async def test_a_reconnect_re_arms_it(self):
+        from backend.app import main as main_module
+
+        await self._edge(self._state(), main_module)
+        await self._edge(self._state(connected=False), main_module)
+        names = await self._edge(self._state(), main_module)
+
+        assert any(name.startswith("prime-kprofiles") for name in names)

+ 5 - 0
backend/tests/unit/test_printer_kill_switch.py

@@ -76,6 +76,7 @@ async def test_unauthorized_active_print_triggers_stop(monkeypatch):
         remaining_time=0,
         layer_num=0,
         temperatures={},
+        nozzles=[],
         raw_data={},
         stg_cur=0,
         # Real PrinterState always carries these; the status-broadcast dedup
@@ -166,6 +167,7 @@ async def test_bambuddy_authorized_print_is_not_stopped(monkeypatch):
         remaining_time=0,
         layer_num=0,
         temperatures={},
+        nozzles=[],
         raw_data={},
         stg_cur=0,
         # Real PrinterState always carries these; the status-broadcast dedup
@@ -243,6 +245,7 @@ async def test_unauthorized_print_state_is_cleared_when_print_ends(monkeypatch):
         remaining_time=0,
         layer_num=0,
         temperatures={},
+        nozzles=[],
         raw_data={},
         stg_cur=0,
         # Real PrinterState always carries these; the status-broadcast dedup
@@ -270,6 +273,7 @@ async def test_unauthorized_print_state_is_cleared_when_print_ends(monkeypatch):
         remaining_time=0,
         layer_num=0,
         temperatures={},
+        nozzles=[],
         raw_data={},
         stg_cur=0,
         # Real PrinterState always carries these; the status-broadcast dedup
@@ -351,6 +355,7 @@ async def test_persisted_print_is_authorized_after_restart(monkeypatch, printer_
         remaining_time=600,
         layer_num=50,
         temperatures={},
+        nozzles=[],
         raw_data={},
         stg_cur=0,
         # Real PrinterState always carries these; the status-broadcast dedup

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

@@ -53,6 +53,7 @@ def _state(connected: bool, state: str = "IDLE") -> SimpleNamespace:
         progress=0,
         layer_num=0,
         temperatures={},
+        nozzles=[],
         raw_data={},
         stg_cur=0,
         # Real PrinterState always carries these; the status-broadcast dedup

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

@@ -64,6 +64,7 @@ def _state(trays: list[dict]) -> SimpleNamespace:
         progress=0,
         layer_num=0,
         temperatures={},
+        nozzles=[],
         raw_data={"ams": [{"id": "0", "dry_time": 0, "tray": trays}]},
         stg_cur=0,
         # Real PrinterState always carries these; the status-broadcast dedup