diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py index 9eb1203da..dd7aca303 100644 --- a/backend/app/api/routes/printers.py +++ b/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 diff --git a/backend/app/main.py b/backend/app/main.py index 27b34de51..55c199725 100644 --- a/backend/app/main.py +++ b/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. diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index 5577221cc..3fc6a5378 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/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) diff --git a/backend/app/services/printer_manager.py b/backend/app/services/printer_manager.py index 79803a883..551377273 100644 --- a/backend/app/services/printer_manager.py +++ b/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( diff --git a/backend/app/utils/kprofile_lookup.py b/backend/app/utils/kprofile_lookup.py new file mode 100644 index 000000000..3e18ead4d --- /dev/null +++ b/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 diff --git a/backend/tests/unit/services/test_kprofile_nozzle_buckets_2854.py b/backend/tests/unit/services/test_kprofile_nozzle_buckets_2854.py new file mode 100644 index 000000000..397d57de9 --- /dev/null +++ b/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) diff --git a/backend/tests/unit/test_printer_kill_switch.py b/backend/tests/unit/test_printer_kill_switch.py index 128736e70..e71489b80 100644 --- a/backend/tests/unit/test_printer_kill_switch.py +++ b/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 diff --git a/backend/tests/unit/test_printer_offline_notification.py b/backend/tests/unit/test_printer_offline_notification.py index 94e872a2c..91258a36a 100644 --- a/backend/tests/unit/test_printer_offline_notification.py +++ b/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 diff --git a/backend/tests/unit/test_status_broadcast_ams_slot_config.py b/backend/tests/unit/test_status_broadcast_ams_slot_config.py index 85c2efcb2..768832cad 100644 --- a/backend/tests/unit/test_status_broadcast_ams_slot_config.py +++ b/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