From 14f46510ba339a9d450526ab2d34127aa4605b15 Mon Sep 17 00:00:00 2001 From: maziggy Date: Tue, 25 Aug 2026 17:52:02 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 3 + backend/app/api/routes/printers.py | 32 +- backend/app/main.py | 83 +++++ backend/app/services/bambu_mqtt.py | 58 ++- backend/app/services/printer_manager.py | 25 +- backend/app/utils/kprofile_lookup.py | 66 ++++ .../test_kprofile_nozzle_buckets_2854.py | 350 ++++++++++++++++++ .../tests/unit/test_printer_kill_switch.py | 5 + .../unit/test_printer_offline_notification.py | 1 + .../test_status_broadcast_ams_slot_config.py | 1 + 10 files changed, 586 insertions(+), 38 deletions(-) create mode 100644 backend/app/utils/kprofile_lookup.py create mode 100644 backend/tests/unit/services/test_kprofile_nozzle_buckets_2854.py diff --git a/CHANGELOG.md b/CHANGELOG.md index fd1bacf2e..88e99e862 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,9 @@ All notable changes to Bambuddy will be documented in this file. - **The Windows installer build is split in two so a signing request can wait for a human (SignPath Foundation)** — Release tags are Authenticode-signed through the SignPath Foundation OSS programme, and the production certificate does not sign on demand the way the self-signed test certificate does: every request has to be approved by hand in the SignPath UI, because the Foundation verifies what is being signed and which build it came from. The submitting action waits for that approval with a default timeout of 600 seconds, which is ample when the test policy approves automatically in seconds and far too short once the wait is a person noticing a tag went out. A tag pushed at night would have failed the run ten minutes later with the installer already compiled and thrown away. The compile now ends in its own job that uploads the unsigned artifact and stops; a second job downloads it, signs it, and does the release-facing work, with the wait raised to an hour. Because the artifact is uploaded before the wait begins and is addressed by id, a missed approval window is recovered by re-running the second job alone rather than rebuilding the installer — which is the reason to separate them rather than simply raise the timeout in place. The second job runs for unsigned builds too, so the daily prereleases that are deliberately left unsigned to preserve the signing quota keep going out through exactly one set of alias, artifact and release steps. The property that matters is unchanged and now recorded next to the steps that depend on it: none of the alias, upload or release-attach steps carry `always()`, so GitHub skips all three when signing fails or times out, and an unsigned `.exe` cannot reach a release. Nothing about the signed output changes, and the restructure behaves identically under the test policy — the request simply completes immediately instead of waiting — so it can be proven green before the production certificate arrives. ### Fixed +- **The K value on an AMS slot card went blank after a while, and came back after a backend restart (#2854)** — H2-series trays carry no K value of their own; they report a calibration index, and the number on the card is resolved from that against the printer's calibration table. The printer answers that query per nozzle diameter, and it answers whoever asks — BambuStudio's queries arrive on the same topic Bambuddy listens to — but every response was stored as though it were the whole printer's table. 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-plus-0.6 machine, so the table was left empty and every K value on the card disappeared until something happened to refill it. Responses are now filed under the nozzle they describe, so an empty answer for a size the printer does not have clears only that size. The same change fixes the three spool-assignment paths that look a calibration index up by nozzle diameter and had been quietly finding nothing whenever the last response was for a different nozzle. +- **A slot could show the other nozzle's K value, and a nozzle that had been swapped out could keep showing its own** — Calibration indices are numbered per nozzle, so index 16 exists on each and means something different on each. The card's WebSocket updates keyed on the index alone, so whichever nozzle's table was read last won the slot; only the first render, which comes from a different code path, got it right. Both now resolve a slot through the extruder it feeds, and fall back to which nozzles are actually fitted when that is not enough to single one out. Where neither settles it the card shows nothing rather than a confident wrong number. +- **A fresh Bambuddy showed no K values at all until someone opened the Profiles page** — Nothing read the calibration table when a printer connected. It arrived by luck: a visit to Profiles or Configure Slot, a GitHub backup, or the printer answering somebody else's query. It is now read once per connection, asking only for the nozzle sizes actually fitted. - **Turning Spoolman mode on deleted every built-in slot assignment, and turning it back off did not restore them (#2812, reported by @chitrangdesign)** — The toggle ran an unfiltered `delete(SpoolAssignment)` across every printer. Switching straight back cleared the *other* table instead, so the two directions were symmetric in code and one-way in effect, and the setting auto-saves on a 500 ms debounce with no save button and no confirmation. Opening the settings page to see what the option did was enough to destroy the configuration — the reporter's log shows four toggles in 85 seconds and the assignments never came back. The deletion had a real reason: checks that read both assignment tables would otherwise let a row in the mode you are *not* using answer for the mode you are, which is how #1473 was fixed. That reason has moved to where it belongs: every place that could be confused now asks which mode is active rather than reading whichever table happens to have rows, and nothing is deleted on a toggle. Each mode keeps its own assignments, so switching is reversible and inspecting a mode costs nothing. Six sites needed it, and only two are reads you would guess at. The per-slot K-profile lookup consults the built-in table first and, on a hit with no matching profile, deliberately stops rather than falling through to Spoolman, so a leftover row would have shadowed the Spoolman binding — the symptom #1556 reported from the other direction. Configuring a slot *writes* its K-profile against whichever table answers first, so the same leftover would have filed a calibration against a spool the printer is not using and never written the local one, leaving a calibration that appeared to succeed and then did not apply. And the auto-unlink pass that drops an assignment whose tray no longer matches its fingerprint ends in a delete: left ungated it would have removed the preserved rows one slot at a time as the AMS contents changed under the other mode, undoing the whole point of this more slowly but just as completely. The remaining two are the missing-assignment notification and the queue cost estimate. A sixth, the built-in remaining-weight fallback inside the Spoolman AMS sync, is deliberately left inert: it was unreachable while the table was being emptied, it is keyed by slot rather than by spool, and `create_spool` writes `remaining_weight` unconditionally, so waking it up would seed a stale figure into a brand new Spoolman spool. Existing installs need no migration: their inactive table is already empty, because it was being emptied. - **A print that could not debit a spool said nothing about it (#2812)** — This is what turned the toggle above from an annoyance into lost filament. A print whose assignments existed at print start and were gone by the time it finished resolved its 3MF, read its per-filament grams, resolved its tray, and then skipped the debit because the row no longer existed — logged at INFO, invisible under the default log level, with the completion notification firing as usual. 65.49 g was never deducted and the reporter only noticed because a spool's remaining weight looked wrong. The skip is now a warning that names the grams, and a completed print that failed to charge a tray it drew from raises the missing-spool-assignment notification, not only the print-start check that runs before the job and was correct to stay quiet. The two are different statements — the first says the weight may not be tracked, the second says it was not — so a print warned at start may notify twice, which is the right trade. This is independent of the toggle and catches any other cause of an assignment disappearing mid-print. - **Print cost ignored the linked Spoolman spool's price and always used the default rate (#2591, reported by @khaosdoctor)** — Spoolman holds per-spool pricing, and #261 gave that as the reason for integrating with it, but nothing ever read it. A print's cost is set once, at archive time, from the built-in Filament catalogue matched on the *primary* type, falling back to a global default rate — and in Spoolman mode nothing revisited that figure afterwards. The per-spool recompute that would have fixed it runs only over rows the built-in inventory writes, and Spoolman mode hands the usage tracker `spoolman_owns_usage` at print start so it writes none. On an install with an empty catalogue, which is what the reporter had, every print came out at the default no matter what the spool cost. Multi-material was wrong twice over: the primary type's rate was applied to the whole print's weight, so a slot of expensive PA was billed at the price of the PLA beside it. Each slot is now priced from the spool it was actually charged to, as the charge is made, and the per-slot costs are summed — which is what fixes the multi-material case rather than a separate change. The rate is the spool's own `price` when set, else the filament's, divided by `filament.weight`; that is the same net-grams field the remain%-delta path already divides by, so a spool that can be charged by percentage can always be priced. A missing or zero price is read as unpriced rather than free, and those grams — along with a tray that has no Spoolman row and any filament the sliced file never attributed — are covered at the default rate in one subtraction against the archive's own total, so a partially priced print still reports a whole-print figure instead of a fraction of one (#1344 in the other inventory mode). Only the first run writes the archive's cost, matching the built-in writer (#1378). Pricing is applied even when the slot-to-tray mapping was a positional guess, unlike the colour and material rewrites beside it: those overwrite what the slicer recorded, whereas the grams have already been deducted from these spools and the archive should say what that deduction was worth. 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