From 18938a10ee6adacf17b069d9a372e02b43608d5e Mon Sep 17 00:00:00 2001 From: maziggy Date: Sat, 1 Aug 2026 10:35:22 +0200 Subject: [PATCH] fix(kprofiles): stop reporting rejected K-profile writes as saved Saving a K-profile was fire-and-forget. set_kprofiles_batch published and returned True, and the printer's extrusion_cali_set answer was logged at DEBUG and dropped, so a write the printer refused was reported to the user as saved (#2718, reporter @jmoore-skild). The reason it could not simply be gated on: the answer itself was wrong. Single-nozzle firmware returned result:"fail" with reason:"invalid tray_id" on writes that demonstrably applied. Measured against an X1C and an H2D over MQTT, the cause is the tray_id:-1 Bambuddy itself put in the payload. Sending three otherwise identical writes isolated it: tray_id:-1 fails, tray_id:0 succeeds, and cali_idx:-1 is accepted either way, so only that one field is at fault. The H2D ignores the value entirely; the X1C validates it, complains, and applies the write anyway. BambuStudio always sends a real tray_id and defaults it to 0 for a manually entered profile. With tray_id:0 the acknowledgement is honest, and the printer echoes back the sequence_id we sent -- confirmed for extrusion_cali_get, _set and _del on both printer classes -- so it can be matched to the write that caused it. Writes now return their sequence_id and the routes await the verdict, turning a real failure into an error that carries the printer's own reason. A printer that stays silent is still treated as success: no answer is not evidence of refusal, and firmware that never answers must not turn every save into an error. Raises the ack to INFO. It sat at DEBUG, so the one line that explains a failed save was absent from every support bundle -- the same reasoning that put ams_filament_drying at INFO for #1447. Also fixes extrusion_cali_set building its payload from str(self._sequence_id) without incrementing first, reusing the previous command's id. Harmless while nothing correlated on it, fatal now that the write path does. Adds supports_nozzle_flow_type() for the Standard / High Flow choice, which the K-Profiles UI previously showed as "Not reported by printer" -- not a value anyone can save. Most printers omit the nozzle identity from their calibration table entirely, and the slicer treats that as Standard rather than unknown; Bambuddy now does the same and keeps the choice editable. The field is hidden only where the model ships a single nozzle variant, using the slicer's own rule (len(nozzle_volume) // len(nozzle_diameter) > 1 over the machine preset) evaluated across every bundled Bambu profile. That puts only A1, A1 Mini and A2L on the hidden side -- it is not the single- versus-dual-nozzle split, since P1P, P1S, P2S, X1, X1C, X1E and H2S are all single-nozzle and all carry two variants. Editing a profile also no longer writes back an empty nozzle_id. Wiki records that on printers which omit the field the chosen flow type is discarded by the firmware and reads back as Standard, in Bambu Studio as well, so it does not get filed as a bug again. --- CHANGELOG.md | 2 + backend/app/api/routes/kprofiles.py | 18 ++ backend/app/schemas/printer.py | 8 + backend/app/services/bambu_mqtt.py | 133 +++++++-- backend/app/utils/printer_models.py | 49 ++++ .../tests/unit/services/test_bambu_mqtt.py | 124 +++++++++ backend/tests/unit/test_printer_models.py | 37 +++ frontend/src/api/client.ts | 4 + frontend/src/components/KProfilesView.tsx | 109 ++++---- frontend/src/i18n/locales/de.ts | 1 - frontend/src/i18n/locales/en.ts | 1 - frontend/src/i18n/locales/es.ts | 1 - frontend/src/i18n/locales/fr.ts | 1 - frontend/src/i18n/locales/it.ts | 1 - frontend/src/i18n/locales/ja.ts | 1 - frontend/src/i18n/locales/ko.ts | 1 - frontend/src/i18n/locales/pt-BR.ts | 1 - frontend/src/i18n/locales/ru.ts | 1 - frontend/src/i18n/locales/tr.ts | 1 - frontend/src/i18n/locales/uk.ts | 1 - frontend/src/i18n/locales/zh-CN.ts | 1 - frontend/src/i18n/locales/zh-TW.ts | 1 - .../{index-E-CRp_kM.js => index-D27DV9N0.js} | 258 +++++++++--------- static/index.html | 2 +- 24 files changed, 539 insertions(+), 218 deletions(-) rename static/assets/{index-E-CRp_kM.js => index-D27DV9N0.js} (80%) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc5452435..67d713537 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ All notable changes to Bambuddy will be documented in this file. ## [1.2.6b1] - Unreleased ### Fixed +- **A rejected K-profile write is now reported as rejected (#2718, reporter @jmoore-skild)** — Saving a K-profile was fire-and-forget: Bambuddy published the command and reported success the moment the bytes left the process. The printer does answer, and the answer was received, matched, and thrown away at debug level — so a write the printer refused for a real reason still told you it was saved. The complication was that the answer itself was wrong: on single-nozzle printers it came back `result: "fail", reason: "invalid tray_id"` on writes that demonstrably applied, which made gating on it look impossible. Measuring against an X1C and an H2D found the cause — the `tray_id: -1` Bambuddy itself put in the payload. The X1C's firmware validates that field and rejects the value while applying the write anyway; the H2D ignores it. Sending `0`, as BambuStudio does, makes the acknowledgement honest, and the printer echoes back the sequence number we sent, so it can be matched to the write that caused it. Saving or deleting a profile now waits for that answer and surfaces a genuine rejection as an error instead of a success toast. A printer that stays silent is still treated as success — no answer is not evidence of refusal. The acknowledgement is also logged at INFO now, so it appears in a support bundle. Covered by backend tests. +- **The K-profile flow type is a real choice again** — On most printers the calibration table comes back with no nozzle identity at all, and Bambuddy had started showing "Not reported by printer" in the Flow Type field as a result. That is not a value you can save, and it isn't what the slicer does: BambuStudio treats a missing nozzle identity as **Standard** and leaves the choice editable. Bambuddy now does the same. The field is hidden only on models sold with a single nozzle variant — the A1, A1 Mini and A2L — using the same rule the slicer applies. This is not the single-versus-dual-nozzle split: the P1P, P1S, P2S, X1, X1 Carbon, X1E and H2S are all single-nozzle and all offer both flows. Editing a profile also no longer strips the nozzle identity from what it writes back. - **Dialogs no longer act after they have closed** — The AMS slot configuration and K-Profile dialogs hold their success state briefly and then close themselves, between 1.5 and 4 seconds after the command is sent so the printer has time to process it. That timer ran whether or not the dialog was still open, so dismissing it — or the printer card refreshing underneath it — within that window left a pending close that fired later, dismissing whatever dialog happened to be open by then. The deferred close is now cancelled when the dialog goes away. Covered by frontend tests. - **A printer with no K-profiles can now be given its first one (#2719, reporter @jmoore-skild)** — **Add K-Profile** built its Filament dropdown out of the profiles already on the printer, so on a printer with none the field was empty, required, and impossible to satisfy — the modal even said so, telling you to go and create the profile in Bambu Studio instead. The filament picker is now populated the way every other one in Bambuddy is, in the same order: **Imported** presets first, then **Orca Cloud**, then **Bambu Cloud**, then Bambuddy's built-in Bambu filament table. That last tier is compiled in, so the list is never empty — a brand-new printer with no cloud account and nothing imported still gets you a profile. The per-printer-model copies a cloud account carries ("Bambu PLA Basic" once for the X1C, once for the P1S, once for the A1) are collapsed into a single row, and the built-in table — a static copy of the same Bambu catalogue — no longer echoes back filaments the groups above already list. Your imported and Orca Cloud libraries are both shown in full even where they overlap by name, because they are usually the same profiles reached two ways and each group is worth seeing under its own heading. The picker is a searchable list with the source heading shown as a real, legible group header — a native dropdown can't do that, since browsers render the group label of a ` f.id === filamentChoice); if (selectedFilament) { - const flowLabel = newNozzleType === 'HH00' ? 'HF' : 'S'; + const flowLabel = newNozzleType === HIGH_FLOW ? 'HF' : 'S'; setName(`${flowLabel} ${selectedFilament.name}`); } } @@ -604,14 +615,8 @@ function KProfileModal({ disabled={!!profile} className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${profile ? 'opacity-60 cursor-not-allowed' : ''}`} > - {/* Only reachable when editing a profile the printer - reported without a nozzle_id — the select is disabled - there, so this is a readout, not a choice. */} - {nozzleType === '' && ( - - )} - - + +
@@ -993,21 +998,6 @@ export function KProfilesView() { return builtinFilamentMap.get(profile.filament_id) || extractFilamentName(profile.name); }, [builtinFilamentMap]); - // Whether the printer reports a nozzle_id at all. Single-nozzle models omit - // it from every extrusion_cali_get entry (#1748), so a flow-type filter there - // could only ever match nothing — hide it instead of offering a control that - // silently empties the list. - const hasFlowTypeInfo = React.useMemo( - () => (kprofiles?.profiles ?? []).some((p) => getFlowTypeLabel(p.nozzle_id) !== ''), - [kprofiles?.profiles] - ); - - // Don't strand the list behind a filter whose control just disappeared - // (switching printers, or a refetch that no longer carries nozzle ids). - useEffect(() => { - if (!hasFlowTypeInfo) setFlowTypeFilter('all'); - }, [hasFlowTypeInfo]); - // Filter and sort profiles // Note: nozzle diameter filtering is done server-side via MQTT request const filteredProfiles = React.useMemo(() => { @@ -1054,6 +1044,18 @@ export function KProfilesView() { const selectedPrinterData = printers?.find((p) => p.id === selectedPrinter); const isDualNozzle = selectedPrinterData?.nozzle_count === 2; + // Whether this printer model is sold with both Standard and High Flow + // nozzles. Comes from the model, not from whether the payload happened to + // carry a nozzle_id — most printers omit that field entirely (#1748) while + // still offering both flows. Only the A-series has a single variant. + const supportsFlowType = selectedPrinterData?.supports_nozzle_flow_type ?? true; + + // Don't strand the list behind a filter whose control just disappeared. + useEffect(() => { + if (!supportsFlowType) setFlowTypeFilter('all'); + }, [supportsFlowType]); + + // Keyboard shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -1145,10 +1147,10 @@ export function KProfilesView() { name: p.name, k_value: parseFloat(p.k_value).toFixed(6), filament_id: p.filament_id, - // Keep an absent nozzle_id absent. Exports from single-nozzle - // printers carry none (#1748), and HH00 vs HS00 is a coin flip - // we'd be writing to the printer as if it were fact. - nozzle_id: p.nozzle_id || '', + // An export from a printer that reports no nozzle_id carries + // none; fall back to Standard, the same default the slicer's + // parser uses for a missing field. + nozzle_id: p.nozzle_id || `${STANDARD_FLOW}-${nozzleDiameter}`, nozzle_diameter: p.nozzle_diameter || nozzleDiameter, extruder_id: p.extruder_id ?? 0, slot_id: 0, // Always create new @@ -1394,7 +1396,7 @@ export function KProfilesView() {
)} - {hasFlowTypeInfo && ( + {supportsFlowType && (
without a
`);let s=e.getAttribute(`formaction`)||o.getAttribute(`action`);if(r=s?hn(s,t):null,n=e.getAttribute(`formmethod`)||o.getAttribute(`method`)||zr,i=Qr(e.getAttribute(`formenctype`))||Qr(o.getAttribute(`enctype`))||Br,a=new FormData(o,e),!Xr()){let{name:t,type:n,value:r}=e;if(n===`image`){let e=t?`${t}.`:``;a.append(`${e}x`,`0`),a.append(`${e}y`,`0`)}else t&&a.append(t,r)}}else if(Vr(e))throw Error(`Cannot submit element that is not ,