mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04:00
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.
This commit is contained in:
commit
c765d2f2fb
24 changed files with 539 additions and 218 deletions
|
|
@ -11,6 +11,8 @@ All notable changes to Bambuddy will be documented in this file.
|
|||
## [1.2.5.1] - 2026-07-27
|
||||
|
||||
### 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 `<select>` in small grey italics and ignore styling on it. Imported and Orca Cloud presets carry no Bambu filament ID, and the printer indexes its calibration table by one, so those are filed under the closest generic for their material — the same rule the AMS slot configuration already uses, so a profile created here matches the slot configured there. A filament whose material Bambuddy can't place is refused with an explanation rather than written under a wrong ID. Also drops a second, redundant K-profile fetch the old dropdown needed: it ran concurrently with the main one whenever a non-0.4mm nozzle was selected, which is exactly the case that made K-profile requests time out. Translated in all locales; wiki updated. Covered by frontend tests.
|
||||
- **K-profiles no longer all report 0.4mm / High Flow (#1748, reporters @Liquidmasl and @jmoore-skild)** — On any printer running a nozzle other than 0.4mm, every K-profile showed up as `0.4` with a flow type nobody had set, and the same profile disagreed with itself: the list said **S**, the edit dialog said **High Flow**. The printer reports the nozzle diameter once, on the response envelope; the individual profile entries carry no diameter and no nozzle id at all. Bambuddy read the diameter *per entry* and fell back to a hardcoded `0.4` when it wasn't there — which was always. The envelope value is now used, so profiles report the nozzle they were actually calibrated for. This was not only cosmetic. Editing a profile is delete-and-re-add on single-nozzle printers, and the dialog rebuilt the nozzle fields from its own (greyed-out) dropdowns, so saving an untouched 0.6mm profile rewrote it on the printer as 0.4mm High Flow. Deleting one aimed the command at the wrong nozzle for the same reason. Both now pass through exactly what the printer reported. Assigning a spool's stored calibration to an AMS slot was affected too: that lookup matches on nozzle diameter, so on a 0.6 or 0.8 nozzle it never found the printer-side entry and the cali_idx silently failed to stick — the "can't auto-map a K-profile" half of the report. Where the printer sends no nozzle id, Bambuddy now says so instead of picking one: the list shows the diameter alone, the dialog shows **Not reported by printer**, and the High Flow / Standard filter is hidden rather than offered as a control that can only ever empty the list. Also fixes the flow type filter selecting the opposite label when naming a new profile. Translated in all locales. Covered by backend tests.
|
||||
|
|
|
|||
|
|
@ -148,6 +148,9 @@ async def set_kprofile(
|
|||
)
|
||||
if not delete_success:
|
||||
raise HTTPException(500, "Failed to delete existing K-profile for edit")
|
||||
ok, detail = await client.await_cali_ack(delete_success)
|
||||
if not ok:
|
||||
raise HTTPException(500, f"Printer rejected the K-profile edit: {detail}")
|
||||
|
||||
# Wait for printer to process the delete before adding
|
||||
await asyncio.sleep(0.5)
|
||||
|
|
@ -179,6 +182,13 @@ async def set_kprofile(
|
|||
if not success:
|
||||
raise HTTPException(500, "Failed to send K-profile command")
|
||||
|
||||
# The printer answers extrusion_cali_set with result/reason, echoing our
|
||||
# sequence_id. Until #2718 that answer was logged at DEBUG and discarded,
|
||||
# so a rejected write was reported to the user as saved.
|
||||
ok, detail = await client.await_cali_ack(success)
|
||||
if not ok:
|
||||
raise HTTPException(500, f"Printer rejected the K-profile: {detail}")
|
||||
|
||||
message = "K-profile updated successfully" if is_edit else "K-profile added successfully"
|
||||
return {"success": True, "message": message}
|
||||
|
||||
|
|
@ -239,6 +249,10 @@ async def set_kprofiles_batch(
|
|||
if not success:
|
||||
raise HTTPException(500, "Failed to send K-profiles batch command")
|
||||
|
||||
ok, detail = await client.await_cali_ack(success)
|
||||
if not ok:
|
||||
raise HTTPException(500, f"Printer rejected the K-profiles: {detail}")
|
||||
|
||||
return {"success": True, "message": f"Added {len(profiles)} K-profiles"}
|
||||
|
||||
|
||||
|
|
@ -283,6 +297,10 @@ async def delete_kprofile(
|
|||
if not success:
|
||||
raise HTTPException(500, "Failed to send K-profile delete command")
|
||||
|
||||
ok, detail = await client.await_cali_ack(success)
|
||||
if not ok:
|
||||
raise HTTPException(500, f"Printer rejected the delete: {detail}")
|
||||
|
||||
# Wait for printer to process the delete before frontend refetches
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ from datetime import datetime
|
|||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from backend.app.utils.printer_models import supports_nozzle_flow_type
|
||||
|
||||
|
||||
class PrinterBase(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
|
|
@ -81,6 +83,11 @@ class PrinterResponse(PrinterBase):
|
|||
id: int
|
||||
is_active: bool
|
||||
nozzle_count: int = 1 # 1 or 2, auto-detected from MQTT
|
||||
# Whether the model is sold with both Standard and High Flow nozzles, so a
|
||||
# K-profile's flow type is a real choice rather than a meaningless field.
|
||||
# Derived from the model, not from nozzle_count — see
|
||||
# printer_models.supports_nozzle_flow_type.
|
||||
supports_nozzle_flow_type: bool = True
|
||||
print_hours_offset: float = 0.0
|
||||
external_camera_url: str | None = None
|
||||
external_camera_type: str | None = None
|
||||
|
|
@ -113,6 +120,7 @@ class PrinterResponse(PrinterBase):
|
|||
"camera_rotation": printer.camera_rotation,
|
||||
"is_active": printer.is_active,
|
||||
"nozzle_count": printer.nozzle_count,
|
||||
"supports_nozzle_flow_type": supports_nozzle_flow_type(printer.model),
|
||||
"print_hours_offset": printer.print_hours_offset,
|
||||
"plate_detection_enabled": printer.plate_detection_enabled,
|
||||
"created_at": printer.created_at,
|
||||
|
|
|
|||
|
|
@ -818,6 +818,11 @@ class BambuMQTTClient:
|
|||
# Value: {"nozzle": str, "event": asyncio.Event, "profiles": list | None}.
|
||||
self._sequence_id: int = 0
|
||||
self._pending_kprofile_requests: dict[str, dict] = {}
|
||||
# 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
|
||||
# by await_cali_ack.
|
||||
self._pending_cali_acks: dict[str, dict | None] = {}
|
||||
|
||||
# Xcam hold timers - OrcaSlicer pattern: ignore incoming data for 3 seconds after command
|
||||
# Key: module_name, Value: timestamp when command was sent
|
||||
|
|
@ -1618,7 +1623,24 @@ class BambuMQTTClient:
|
|||
if "command" in print_data:
|
||||
cmd = print_data.get("command")
|
||||
logger.debug("[%s] Received command response: %s", self.serial_number, cmd)
|
||||
if cmd in ("extrusion_cali_sel", "extrusion_cali_set", "extrusion_cali_del", "ams_filament_setting"):
|
||||
if cmd in ("extrusion_cali_set", "extrusion_cali_del"):
|
||||
# INFO, not debug: this is the printer's verdict on a write
|
||||
# the user just made, and it was invisible in support
|
||||
# bundles for as long as it sat at DEBUG (#2718). Same
|
||||
# reasoning as ams_filament_drying below.
|
||||
logger.info(
|
||||
"[%s] %s response: result=%s reason=%s seq=%s",
|
||||
self.serial_number,
|
||||
cmd,
|
||||
print_data.get("result"),
|
||||
print_data.get("reason", ""),
|
||||
print_data.get("sequence_id"),
|
||||
)
|
||||
logger.debug("[%s] %s full response: %s", self.serial_number, cmd, print_data)
|
||||
ack_seq = str(print_data.get("sequence_id", ""))
|
||||
if ack_seq in self._pending_cali_acks:
|
||||
self._pending_cali_acks[ack_seq] = print_data
|
||||
elif cmd in ("extrusion_cali_sel", "ams_filament_setting"):
|
||||
logger.debug("[%s] %s response: %s", self.serial_number, cmd, print_data)
|
||||
# AMS drying responses are rare (user-initiated only) and the
|
||||
# full payload — including `result` and any `reason` code —
|
||||
|
|
@ -5602,6 +5624,51 @@ class BambuMQTTClient:
|
|||
logger.error("[%s] Failed to get K-profiles after %s attempts", self.serial_number, max_retries)
|
||||
return []
|
||||
|
||||
def _publish_cali_write(self, command: dict, seq_id: str) -> bool:
|
||||
"""Publish a K-profile write and arm its ack slot.
|
||||
|
||||
Registration happens before the publish because the printer answers in
|
||||
well under a second — measured at 70-150ms — which is comfortably
|
||||
before an async caller gets back to awaiting.
|
||||
"""
|
||||
self._pending_cali_acks[seq_id] = None
|
||||
try:
|
||||
self._client.publish(self.topic_publish, json.dumps(command), qos=1)
|
||||
except Exception:
|
||||
self._pending_cali_acks.pop(seq_id, None)
|
||||
raise
|
||||
return True
|
||||
|
||||
async def await_cali_ack(self, seq_id: str, timeout: float = 6.0) -> tuple[bool, str]:
|
||||
"""Wait for the printer's verdict on a K-profile write.
|
||||
|
||||
Returns ``(ok, detail)``. ``ok`` is False only when the printer
|
||||
explicitly said ``result: "fail"`` — a timeout returns True with a
|
||||
detail string, because "no answer" is not evidence of rejection and
|
||||
older firmware may not answer at all. Callers that need certainty read
|
||||
the calibration table back.
|
||||
|
||||
Polled rather than event-driven on purpose: the ack is filled in by the
|
||||
MQTT callback thread, and polling a dict costs one lookup every 50ms
|
||||
for at most a few hundred milliseconds, against the cross-thread
|
||||
event plumbing it would otherwise take.
|
||||
"""
|
||||
deadline = time.monotonic() + timeout
|
||||
try:
|
||||
while time.monotonic() < deadline:
|
||||
ack = self._pending_cali_acks.get(seq_id)
|
||||
if ack is not None:
|
||||
result = str(ack.get("result", "")).lower()
|
||||
reason = str(ack.get("reason", "") or "")
|
||||
if result == "fail":
|
||||
return (False, reason or "printer reported failure")
|
||||
return (True, reason)
|
||||
await asyncio.sleep(0.05)
|
||||
finally:
|
||||
self._pending_cali_acks.pop(seq_id, None)
|
||||
logger.warning("[%s] No ack for K-profile write seq=%s within %.1fs", self.serial_number, seq_id, timeout)
|
||||
return (True, "no acknowledgement from printer")
|
||||
|
||||
def set_kprofile(
|
||||
self,
|
||||
filament_id: str,
|
||||
|
|
@ -5613,7 +5680,7 @@ class BambuMQTTClient:
|
|||
setting_id: str | None = None,
|
||||
slot_id: int = 0,
|
||||
cali_idx: int | None = None,
|
||||
) -> bool:
|
||||
) -> str | None:
|
||||
"""Set/update a K-profile on the printer.
|
||||
|
||||
Args:
|
||||
|
|
@ -5628,13 +5695,16 @@ class BambuMQTTClient:
|
|||
cali_idx: For edits, the existing slot being edited (enables in-place edit)
|
||||
|
||||
Returns:
|
||||
True if command was sent, False otherwise
|
||||
The sequence_id the command was sent under, so the caller can
|
||||
await the printer's verdict via await_cali_ack. None if the
|
||||
command could not be sent.
|
||||
"""
|
||||
if not self._client or not self.state.connected:
|
||||
logger.warning("[%s] Cannot set K-profile: not connected", self.serial_number)
|
||||
return False
|
||||
return None
|
||||
|
||||
self._sequence_id += 1
|
||||
seq_id = str(self._sequence_id)
|
||||
|
||||
# Build the filament entry - printer uses cali_idx for profile identification
|
||||
# For new profiles (slot_id=0), use cali_idx=-1 to tell printer to create new slot
|
||||
|
|
@ -5662,7 +5732,13 @@ class BambuMQTTClient:
|
|||
"nozzle_diameter": nozzle_diameter,
|
||||
"nozzle_id": nozzle_id,
|
||||
"setting_id": setting_id if setting_id else "",
|
||||
"tray_id": -1,
|
||||
# 0, not -1. Single-nozzle firmware validates this field and
|
||||
# answers `result: "fail", reason: "invalid tray_id"` to -1 — while
|
||||
# applying the write anyway, so the rejection looked like noise.
|
||||
# Measured on an X1C: flipping only this value turns the ack into
|
||||
# `success` (#2718). BambuStudio always sends a real tray_id and
|
||||
# defaults it to 0 for a manually entered profile.
|
||||
"tray_id": 0,
|
||||
}
|
||||
|
||||
command = {
|
||||
|
|
@ -5670,7 +5746,7 @@ class BambuMQTTClient:
|
|||
"command": "extrusion_cali_set",
|
||||
"filaments": [filament_entry],
|
||||
"nozzle_diameter": nozzle_diameter,
|
||||
"sequence_id": str(self._sequence_id),
|
||||
"sequence_id": seq_id,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -5679,14 +5755,14 @@ class BambuMQTTClient:
|
|||
f"[{self.serial_number}] Setting K-profile: {name} = {k_value} (cali_idx={effective_cali_idx}, new={slot_id == 0})"
|
||||
)
|
||||
logger.debug("[%s] K-profile SET command: %s", self.serial_number, command_json)
|
||||
self._client.publish(self.topic_publish, command_json, qos=1)
|
||||
return True
|
||||
self._publish_cali_write(command, seq_id)
|
||||
return seq_id
|
||||
|
||||
def set_kprofiles_batch(
|
||||
self,
|
||||
profiles: list[dict],
|
||||
nozzle_diameter: str = "0.4",
|
||||
) -> bool:
|
||||
) -> str | None:
|
||||
"""Set multiple K-profiles in a single command (for dual-nozzle).
|
||||
|
||||
Args:
|
||||
|
|
@ -5695,15 +5771,17 @@ class BambuMQTTClient:
|
|||
nozzle_diameter: Common nozzle diameter for all profiles
|
||||
|
||||
Returns:
|
||||
True if command was sent, False otherwise
|
||||
The sequence_id the command was sent under (see set_kprofile),
|
||||
or None if it could not be sent.
|
||||
"""
|
||||
if not self._client or not self.state.connected:
|
||||
logger.warning("[%s] Cannot set K-profiles batch: not connected", self.serial_number)
|
||||
return False
|
||||
return None
|
||||
|
||||
import random
|
||||
|
||||
self._sequence_id += 1
|
||||
seq_id = str(self._sequence_id)
|
||||
|
||||
filament_entries = []
|
||||
for p in profiles:
|
||||
|
|
@ -5731,7 +5809,9 @@ class BambuMQTTClient:
|
|||
"nozzle_diameter": nozzle_diameter,
|
||||
"nozzle_id": p.get("nozzle_id", f"HS00-{nozzle_diameter}"),
|
||||
"setting_id": setting_id if setting_id else "",
|
||||
"tray_id": -1,
|
||||
# See set_kprofile: -1 is rejected as "invalid tray_id" by
|
||||
# single-nozzle firmware even though the write lands (#2718).
|
||||
"tray_id": 0,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -5740,15 +5820,15 @@ class BambuMQTTClient:
|
|||
"command": "extrusion_cali_set",
|
||||
"filaments": filament_entries,
|
||||
"nozzle_diameter": nozzle_diameter,
|
||||
"sequence_id": str(self._sequence_id),
|
||||
"sequence_id": seq_id,
|
||||
}
|
||||
}
|
||||
|
||||
command_json = json.dumps(command)
|
||||
logger.info("[%s] Setting %s K-profiles in batch", self.serial_number, len(filament_entries))
|
||||
logger.debug("[%s] K-profile SET batch command: %s", self.serial_number, command_json)
|
||||
self._client.publish(self.topic_publish, command_json, qos=1)
|
||||
return True
|
||||
self._publish_cali_write(command, seq_id)
|
||||
return seq_id
|
||||
|
||||
def delete_kprofile(
|
||||
self,
|
||||
|
|
@ -5758,7 +5838,7 @@ class BambuMQTTClient:
|
|||
nozzle_diameter: str = "0.4",
|
||||
extruder_id: int = 0,
|
||||
setting_id: str | None = None,
|
||||
) -> bool:
|
||||
) -> str | None:
|
||||
"""Delete a K-profile from the printer.
|
||||
|
||||
Args:
|
||||
|
|
@ -5770,13 +5850,15 @@ class BambuMQTTClient:
|
|||
setting_id: Unique setting identifier (for X1C series)
|
||||
|
||||
Returns:
|
||||
True if command was sent, False otherwise
|
||||
The sequence_id the command was sent under (see set_kprofile),
|
||||
or None if it could not be sent.
|
||||
"""
|
||||
if not self._client or not self.state.connected:
|
||||
logger.warning("[%s] Cannot delete K-profile: not connected", self.serial_number)
|
||||
return False
|
||||
return None
|
||||
|
||||
self._sequence_id += 1
|
||||
seq_id = str(self._sequence_id)
|
||||
|
||||
# Dual-nozzle K-profile delete uses the extruder_id/nozzle_id format;
|
||||
# single-nozzle printers (X1C/P1/A1/P2S/H2S) need the setting_id form.
|
||||
|
|
@ -5792,7 +5874,7 @@ class BambuMQTTClient:
|
|||
command = {
|
||||
"print": {
|
||||
"command": "extrusion_cali_del",
|
||||
"sequence_id": str(self._sequence_id),
|
||||
"sequence_id": seq_id,
|
||||
"extruder_id": extruder_id,
|
||||
"nozzle_id": nozzle_id,
|
||||
"filament_id": filament_id,
|
||||
|
|
@ -5806,7 +5888,7 @@ class BambuMQTTClient:
|
|||
command = {
|
||||
"print": {
|
||||
"command": "extrusion_cali_del",
|
||||
"sequence_id": str(self._sequence_id),
|
||||
"sequence_id": seq_id,
|
||||
"filament_id": filament_id,
|
||||
"cali_idx": cali_idx,
|
||||
"setting_id": setting_id if setting_id else "",
|
||||
|
|
@ -5821,9 +5903,9 @@ class BambuMQTTClient:
|
|||
f"[{self.serial_number}] Deleting K-profile: cali_idx={cali_idx}, filament={filament_id}, setting_id={setting_id}, dual={is_dual_nozzle}"
|
||||
)
|
||||
logger.debug("[%s] K-profile DELETE command: %s", self.serial_number, command_json)
|
||||
# Use QoS 1 for reliable delivery (at least once)
|
||||
self._client.publish(self.topic_publish, command_json, qos=1)
|
||||
return True
|
||||
# QoS 1 for reliable delivery (at least once)
|
||||
self._publish_cali_write(command, seq_id)
|
||||
return seq_id
|
||||
|
||||
# =========================================================================
|
||||
# Printer Control Commands
|
||||
|
|
@ -6666,6 +6748,11 @@ class BambuMQTTClient:
|
|||
logger.warning("[%s] Cannot set K value: not connected", self.serial_number)
|
||||
return False
|
||||
|
||||
# Was reusing the previous command's id — harmless while nothing
|
||||
# correlated on it, but the printer echoes sequence_id back and the
|
||||
# K-profile write path now matches acks by it (#2718).
|
||||
self._sequence_id += 1
|
||||
|
||||
nozzle_id = f"HS00-{nozzle_diameter}"
|
||||
|
||||
# A2L AMS-Lite: a normalised global tray (24-27) must go out as the
|
||||
|
|
|
|||
|
|
@ -116,6 +116,28 @@ LINEAR_RAIL_MODELS = frozenset(
|
|||
)
|
||||
|
||||
|
||||
# Models sold with a single nozzle flow variant, so a Standard / High Flow
|
||||
# choice on a K-profile is meaningless there. Derived from the slicer's own
|
||||
# rule (len(nozzle_volume) // len(nozzle_diameter) > 1 over the bundled Bambu
|
||||
# machine presets), not from nozzle count — P1P/P1S/P2S/X1/X1C/X1E/H2S are
|
||||
# single-nozzle and all carry two variants. Only the A-series has one.
|
||||
SINGLE_NOZZLE_FLOW_MODELS = frozenset(
|
||||
[
|
||||
# Display names (uppercase, no spaces)
|
||||
"A1",
|
||||
"A1MINI",
|
||||
"A2L",
|
||||
# Internal codes
|
||||
"N1", # A1 Mini
|
||||
"N2S", # A1
|
||||
"N9", # A2L
|
||||
"A04", # A1 Mini (alternate)
|
||||
"A11", # A1
|
||||
"A12", # A1 Mini
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# Models without any external storage (MicroSD / SD card slot).
|
||||
# The A1 and A1 Mini ship with internal storage only — there is no
|
||||
# firmware-side "Store sent files on external storage" toggle and no
|
||||
|
|
@ -290,6 +312,33 @@ def is_dual_nozzle_model(model: str | None) -> bool:
|
|||
return normalized in DUAL_NOZZLE_MODELS
|
||||
|
||||
|
||||
def supports_nozzle_flow_type(model: str | None) -> bool:
|
||||
"""Return True if the model offers a Standard / High Flow nozzle choice.
|
||||
|
||||
A K-profile is filed under a ``nozzle_id`` of the form ``HS00-0.4``
|
||||
(Standard) or ``HH00-0.4`` (High Flow), so the flow type is part of the
|
||||
profile's identity on any printer where both exist — and meaningless noise
|
||||
on one where only a single variant is sold.
|
||||
|
||||
The split is NOT the nozzle count: P1S, P2S, X1C and H2S are single-nozzle
|
||||
and all offer both flows. BambuStudio/OrcaSlicer derive the same capability
|
||||
from the machine preset — ``support_nozzle_volume()`` is
|
||||
``len(nozzle_volume) // len(nozzle_diameter) > 1`` — and every bundled
|
||||
Bambu profile evaluated against that formula puts only the A-series on the
|
||||
"one variant" side (A1 and A1 Mini at 1, A2L at 1; everything from P1P
|
||||
upward at 2 or more per extruder).
|
||||
|
||||
Defaults to True for unknown models: offering the choice on a printer that
|
||||
turns out to have one flow type costs the user a redundant dropdown, while
|
||||
hiding it on one that has two makes half its calibration table
|
||||
unreachable.
|
||||
"""
|
||||
if not model:
|
||||
return True
|
||||
normalized = model.strip().upper().replace(" ", "").replace("-", "")
|
||||
return normalized not in SINGLE_NOZZLE_FLOW_MODELS
|
||||
|
||||
|
||||
def get_rod_type(model: str | None) -> str | None:
|
||||
"""Return the rod/rail type for a printer model.
|
||||
|
||||
|
|
|
|||
|
|
@ -6916,6 +6916,130 @@ class TestKProfileNozzleDiameterFromEnvelope:
|
|||
return await client.get_kprofiles(nozzle_diameter=nozzle, timeout=2.0)
|
||||
|
||||
|
||||
class TestKProfileWriteAcks:
|
||||
"""#2718: K-profile writes were fire-and-forget.
|
||||
|
||||
``set_kprofiles_batch`` published and returned True immediately, and the
|
||||
printer's ``extrusion_cali_set`` answer was logged at DEBUG and dropped, so
|
||||
a rejected write was reported to the user as saved. Two facts measured on
|
||||
real hardware shape the fix: the printer echoes our ``sequence_id`` back
|
||||
(so the ack can be correlated), and it answers ``result: "fail",
|
||||
reason: "invalid tray_id"`` to ``tray_id: -1`` on single-nozzle firmware
|
||||
while applying the write anyway — flipping that field to 0 is what makes
|
||||
``result`` trustworthy.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def mqtt_client(self):
|
||||
from backend.app.services.bambu_mqtt import BambuMQTTClient
|
||||
|
||||
client = BambuMQTTClient(
|
||||
ip_address="192.168.1.100",
|
||||
serial_number="X1CTEST",
|
||||
access_code="12345678",
|
||||
)
|
||||
client.state.connected = True
|
||||
client._client = MagicMock()
|
||||
return client
|
||||
|
||||
@staticmethod
|
||||
def _sent(client):
|
||||
return json.loads(client._client.publish.call_args[0][1])["print"]
|
||||
|
||||
def test_set_sends_tray_id_zero(self, mqtt_client):
|
||||
mqtt_client.set_kprofile(filament_id="GFL99", name="test", k_value="0.022000")
|
||||
assert self._sent(mqtt_client)["filaments"][0]["tray_id"] == 0
|
||||
|
||||
def test_batch_sends_tray_id_zero(self, mqtt_client):
|
||||
mqtt_client.set_kprofiles_batch([{"filament_id": "GFL99", "name": "t", "k_value": "0.020000"}])
|
||||
assert self._sent(mqtt_client)["filaments"][0]["tray_id"] == 0
|
||||
|
||||
def test_writers_return_their_sequence_id(self, mqtt_client):
|
||||
seq = mqtt_client.set_kprofile(filament_id="GFL99", name="test", k_value="0.022000")
|
||||
assert seq == self._sent(mqtt_client)["sequence_id"]
|
||||
assert seq in mqtt_client._pending_cali_acks
|
||||
|
||||
def test_writers_return_none_when_disconnected(self, mqtt_client):
|
||||
mqtt_client.state.connected = False
|
||||
assert mqtt_client.set_kprofile(filament_id="GFL99", name="t", k_value="0.02") is None
|
||||
assert mqtt_client.set_kprofiles_batch([{"filament_id": "GFL99"}]) is None
|
||||
assert mqtt_client.delete_kprofile(cali_idx=1, filament_id="GFL99", nozzle_id="HH00-0.4") is None
|
||||
|
||||
def test_per_tray_extrusion_cali_set_advances_the_sequence_id(self, mqtt_client):
|
||||
# It used to reuse the previous command's id, which would silently
|
||||
# defeat the correlation the write path now depends on.
|
||||
before = mqtt_client._sequence_id
|
||||
mqtt_client.extrusion_cali_set(tray_id=0, k_value=0.02)
|
||||
assert mqtt_client._sequence_id > before
|
||||
assert self._sent(mqtt_client)["sequence_id"] == str(mqtt_client._sequence_id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failure_ack_is_reported_as_failure(self, mqtt_client):
|
||||
seq = mqtt_client.set_kprofile(filament_id="GFL99", name="test", k_value="0.022000")
|
||||
mqtt_client._process_message(
|
||||
{
|
||||
"print": {
|
||||
"command": "extrusion_cali_set",
|
||||
"result": "fail",
|
||||
"reason": "invalid tray_id",
|
||||
"sequence_id": seq,
|
||||
}
|
||||
}
|
||||
)
|
||||
ok, detail = await mqtt_client.await_cali_ack(seq, timeout=2.0)
|
||||
assert ok is False
|
||||
assert detail == "invalid tray_id"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_success_ack_passes(self, mqtt_client):
|
||||
seq = mqtt_client.set_kprofile(filament_id="GFL99", name="test", k_value="0.022000")
|
||||
mqtt_client._process_message(
|
||||
{"print": {"command": "extrusion_cali_set", "result": "success", "reason": "", "sequence_id": seq}}
|
||||
)
|
||||
ok, _ = await mqtt_client.await_cali_ack(seq, timeout=2.0)
|
||||
assert ok is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ack_for_another_write_does_not_resolve_this_one(self, mqtt_client):
|
||||
seq = mqtt_client.set_kprofile(filament_id="GFL99", name="test", k_value="0.022000")
|
||||
mqtt_client._process_message(
|
||||
{
|
||||
"print": {
|
||||
"command": "extrusion_cali_set",
|
||||
"result": "fail",
|
||||
"reason": "invalid tray_id",
|
||||
"sequence_id": "999999",
|
||||
}
|
||||
}
|
||||
)
|
||||
# Unrelated sequence_id: this write is still unanswered, so it times
|
||||
# out rather than inheriting someone else's failure.
|
||||
ok, detail = await mqtt_client.await_cali_ack(seq, timeout=0.3)
|
||||
assert ok is True
|
||||
assert "no acknowledgement" in detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_silence_is_not_treated_as_rejection(self, mqtt_client):
|
||||
# Firmware that never answers must not turn every save into an error.
|
||||
seq = mqtt_client.delete_kprofile(cali_idx=1, filament_id="GFL99", nozzle_id="HH00-0.4")
|
||||
ok, detail = await mqtt_client.await_cali_ack(seq, timeout=0.3)
|
||||
assert ok is True
|
||||
assert "no acknowledgement" in detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_slot_is_released(self, mqtt_client):
|
||||
seq = mqtt_client.set_kprofile(filament_id="GFL99", name="test", k_value="0.022000")
|
||||
await mqtt_client.await_cali_ack(seq, timeout=0.3)
|
||||
assert mqtt_client._pending_cali_acks == {}
|
||||
|
||||
def test_delete_ack_is_matched_too(self, mqtt_client):
|
||||
seq = mqtt_client.delete_kprofile(cali_idx=1, filament_id="GFL99", nozzle_id="HH00-0.4")
|
||||
mqtt_client._process_message(
|
||||
{"print": {"command": "extrusion_cali_del", "result": "success", "sequence_id": seq}}
|
||||
)
|
||||
assert mqtt_client._pending_cali_acks[seq]["result"] == "success"
|
||||
|
||||
|
||||
class TestKProfileRequestCorrelation:
|
||||
"""#1748: K-profile requests timed out whenever two were in flight.
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from backend.app.utils.printer_models import (
|
|||
is_dual_nozzle_model,
|
||||
normalize_printer_model,
|
||||
normalize_printer_model_id,
|
||||
supports_nozzle_flow_type,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -213,6 +214,42 @@ class TestDualNozzleModel:
|
|||
assert is_dual_nozzle_model("") is False
|
||||
|
||||
|
||||
class TestSupportsNozzleFlowType:
|
||||
"""Which models offer a Standard / High Flow choice on a K-profile.
|
||||
|
||||
Mirrors the slicer's own rule — BambuStudio/OrcaSlicer gate their
|
||||
Nozzle-Flow control on ``len(nozzle_volume) // len(nozzle_diameter) > 1``
|
||||
read from the machine preset. Evaluated over every bundled Bambu profile,
|
||||
only the A-series lands on one variant. Getting this wrong in the
|
||||
permissive direction shows a redundant dropdown; getting it wrong in the
|
||||
other direction makes half a printer's calibration table unreachable.
|
||||
"""
|
||||
|
||||
def test_a_series_has_one_flow_variant(self):
|
||||
for model in ("A1", "A1 Mini", "A1MINI", "A2L"):
|
||||
assert supports_nozzle_flow_type(model) is False, model
|
||||
|
||||
def test_a_series_internal_codes(self):
|
||||
for code in ("N1", "N2S", "N9", "A04", "A11", "A12"):
|
||||
assert supports_nozzle_flow_type(code) is False, code
|
||||
|
||||
def test_single_nozzle_models_still_offer_both_flows(self):
|
||||
# The split is NOT nozzle count: all of these are single-nozzle and
|
||||
# all carry two nozzle_volume variants in their machine preset.
|
||||
for model in ("X1", "X1C", "X1E", "P1P", "P1S", "P2S", "H2S"):
|
||||
assert supports_nozzle_flow_type(model) is True, model
|
||||
|
||||
def test_dual_nozzle_models_offer_both_flows(self):
|
||||
for model in ("H2D", "H2D Pro", "H2C"):
|
||||
assert supports_nozzle_flow_type(model) is True, model
|
||||
|
||||
def test_unknown_and_empty_default_to_supported(self):
|
||||
# Fail open: a redundant dropdown beats an unreachable half-table.
|
||||
assert supports_nozzle_flow_type(None) is True
|
||||
assert supports_nozzle_flow_type("") is True
|
||||
assert supports_nozzle_flow_type("SomeFuturePrinter") is True
|
||||
|
||||
|
||||
class TestHasExternalStorage:
|
||||
"""Pins which Bambu models have a MicroSD slot. The connection
|
||||
diagnostic flips its ``external_storage`` check from ``fail`` to
|
||||
|
|
|
|||
|
|
@ -357,6 +357,10 @@ export interface Printer {
|
|||
model: string | null;
|
||||
location: string | null; // Group/location name
|
||||
nozzle_count: number; // 1 or 2, auto-detected from MQTT
|
||||
// Model is sold with both Standard and High Flow nozzles, so a K-profile's
|
||||
// flow type is a real choice. Derived from the model, not the nozzle count —
|
||||
// only the A-series has a single variant.
|
||||
supports_nozzle_flow_type: boolean;
|
||||
is_active: boolean;
|
||||
auto_archive: boolean;
|
||||
external_camera_url: string | null;
|
||||
|
|
|
|||
|
|
@ -49,23 +49,26 @@ const truncateK = (value: string) => {
|
|||
return (Math.trunc(num * 1000) / 1000).toFixed(3);
|
||||
};
|
||||
|
||||
// Get flow type label from nozzle_id (e.g., "HH00-0.4" -> "HF", "HS00-0.4" -> "S").
|
||||
// Single-nozzle printers omit nozzle_id from their extrusion_cali_get response
|
||||
// entirely (#1748), and there is no other field to recover the flow type from —
|
||||
// so return '' and let the caller show nothing rather than assert "Standard".
|
||||
const getFlowTypeLabel = (nozzleId: string) => {
|
||||
if (nozzleId.startsWith('HH')) return 'HF'; // High Flow
|
||||
if (nozzleId.startsWith('HS')) return 'S'; // Standard Flow
|
||||
return ''; // not reported by the printer
|
||||
};
|
||||
// nozzle_id encodes the flow type, per the slicer's own generator:
|
||||
// "H" + (Standard ? "S" : "H") + "00" + "-" + diameter
|
||||
// so "HS00-0.4" is Standard and "HH00-0.4" is High Flow. The "00" is a literal,
|
||||
// not a material code.
|
||||
const STANDARD_FLOW = 'HS00';
|
||||
const HIGH_FLOW = 'HH00';
|
||||
|
||||
// Extract nozzle type prefix from nozzle_id (e.g., "HH00-0.4" -> "HH00").
|
||||
// '' when the printer reported no nozzle_id — see getFlowTypeLabel.
|
||||
// Many printers omit nozzle_id from their extrusion_cali_get response entirely
|
||||
// (#1748) — the field simply isn't in the payload. BambuStudio treats that as
|
||||
// Standard (its parser falls back to nvtStandard when the key is absent), and
|
||||
// so do we: the flow type stays a real, editable value rather than a blank.
|
||||
const getNozzleTypePrefix = (nozzleId: string) => {
|
||||
const match = nozzleId.match(/^([A-Z]{2}\d{2})/);
|
||||
return match ? match[1] : '';
|
||||
return match ? match[1] : STANDARD_FLOW;
|
||||
};
|
||||
|
||||
// Short label for the profile list.
|
||||
const getFlowTypeLabel = (nozzleId: string) =>
|
||||
getNozzleTypePrefix(nozzleId) === HIGH_FLOW ? 'HF' : 'S';
|
||||
|
||||
// Extract filament name from profile name (e.g., "High Flow_Devil Design PLA Basic" -> "Devil Design PLA Basic")
|
||||
const extractFilamentName = (profileName: string) => {
|
||||
// Profile names are formatted as "{Flow Type}_{Filament Name}" or "{Flow Type} {Filament Name}"
|
||||
|
|
@ -132,7 +135,7 @@ function KProfileCard({ profile, onEdit, onCopy, selectionMode, isSelected, onTo
|
|||
</span>
|
||||
)}
|
||||
<span className="text-xs text-bambu-gray whitespace-nowrap">
|
||||
{[flowType, diameter].filter(Boolean).join(' ')}
|
||||
{flowType} {diameter}
|
||||
</span>
|
||||
</div>
|
||||
{note && (
|
||||
|
|
@ -165,6 +168,7 @@ interface KProfileModalProps {
|
|||
builtinFilaments?: { filament_id: string; name: string }[]; // Filament ID → name lookup
|
||||
filamentPresets?: FilamentPresetOption[]; // Every filament this install knows, tiered
|
||||
isDualNozzle?: boolean; // Whether this is a dual-nozzle printer
|
||||
supportsFlowType?: boolean; // Model sells both Standard and High Flow nozzles
|
||||
initialNote?: string; // Initial note value for the profile
|
||||
initialNoteKey?: string | null; // Key the note was stored under (for clearing)
|
||||
onClose: () => void;
|
||||
|
|
@ -181,6 +185,7 @@ function KProfileModal({
|
|||
builtinFilaments = [],
|
||||
filamentPresets = [],
|
||||
isDualNozzle = false,
|
||||
supportsFlowType = true,
|
||||
initialNote = '',
|
||||
initialNoteKey = null,
|
||||
onClose,
|
||||
|
|
@ -207,7 +212,7 @@ function KProfileModal({
|
|||
// single-nozzle models never do (#1748) — showing "High Flow" there was the
|
||||
// UI inventing a value the printer never sent.
|
||||
const [nozzleType, setNozzleType] = useState(
|
||||
profile ? getNozzleTypePrefix(profile.nozzle_id) : 'HH00'
|
||||
profile ? getNozzleTypePrefix(profile.nozzle_id) : STANDARD_FLOW
|
||||
);
|
||||
const [modalDiameter, setModalDiameter] = useState(
|
||||
profile?.nozzle_diameter || nozzleDiameter
|
||||
|
|
@ -351,11 +356,14 @@ function KProfileModal({
|
|||
const nozzleId = `${nozzleType}-${modalDiameter}`;
|
||||
|
||||
// An edit is delete + re-add on single-nozzle printers, so the nozzle
|
||||
// fields have to survive the round trip untouched — both selects are
|
||||
// disabled while editing. Rebuilding them from the selects is what let a
|
||||
// 0.6mm profile come back as "HH00-0.4" once the parse defaults had
|
||||
// stamped it 0.4 (#1748); pass through what the printer reported instead.
|
||||
const editNozzleId = profile ? profile.nozzle_id : nozzleId;
|
||||
// fields have to survive the round trip — both selects are disabled while
|
||||
// editing. Rebuilding them blindly from the selects is what let a 0.6mm
|
||||
// profile come back as "HH00-0.4" once the parse defaults had stamped it
|
||||
// 0.4 (#1748), so prefer whatever the printer reported. Where it reported
|
||||
// no nozzle_id at all, send the rebuilt one rather than an empty string —
|
||||
// the field is part of the profile's identity on the wire and the slicer
|
||||
// always populates it.
|
||||
const editNozzleId = profile ? profile.nozzle_id || nozzleId : nozzleId;
|
||||
const editDiameter = profile ? profile.nozzle_diameter : modalDiameter;
|
||||
|
||||
// The printer indexes its calibration table by filament_id, so the preset
|
||||
|
|
@ -562,7 +570,7 @@ function KProfileModal({
|
|||
// Auto-generate the profile name, but never over
|
||||
// an entry the user typed.
|
||||
if (!name) {
|
||||
const flowLabel = nozzleType === 'HH00' ? 'HF' : 'S';
|
||||
const flowLabel = nozzleType === HIGH_FLOW ? 'HF' : 'S';
|
||||
setName(`${flowLabel} ${f.name}`);
|
||||
}
|
||||
}}
|
||||
|
|
@ -582,9 +590,12 @@ function KProfileModal({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Flow Type and Nozzle Size - read-only when editing */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
{/* Flow Type and Nozzle Size - read-only when editing. Flow type
|
||||
is hidden on models sold with a single nozzle variant (the
|
||||
A-series), where the choice would be meaningless — same gate
|
||||
the slicer applies via support_nozzle_volume(). */}
|
||||
<div className={supportsFlowType ? 'grid grid-cols-2 gap-4' : ''}>
|
||||
<div className={supportsFlowType ? '' : 'hidden'}>
|
||||
<label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.flowType')}</label>
|
||||
<select
|
||||
value={nozzleType}
|
||||
|
|
@ -596,7 +607,7 @@ function KProfileModal({
|
|||
if (!profile && filamentChoice && !name) {
|
||||
const selectedFilament = filamentPresets.find(f => 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 === '' && (
|
||||
<option value="">{t('kProfiles.modal.flowTypeNotReported')}</option>
|
||||
)}
|
||||
<option value="HH00">{t('kProfiles.modal.highFlow')}</option>
|
||||
<option value="HS00">{t('kProfiles.modal.standard')}</option>
|
||||
<option value={HIGH_FLOW}>{t('kProfiles.modal.highFlow')}</option>
|
||||
<option value={STANDARD_FLOW}>{t('kProfiles.modal.standard')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -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() {
|
|||
</select>
|
||||
</div>
|
||||
)}
|
||||
{hasFlowTypeInfo && (
|
||||
{supportsFlowType && (
|
||||
<div className="w-32">
|
||||
<select
|
||||
value={flowTypeFilter}
|
||||
|
|
@ -1603,6 +1605,7 @@ export function KProfilesView() {
|
|||
builtinFilaments={enrichedBuiltinFilaments}
|
||||
filamentPresets={filamentPresets}
|
||||
isDualNozzle={isDualNozzle}
|
||||
supportsFlowType={supportsFlowType}
|
||||
initialNote={note}
|
||||
initialNoteKey={key}
|
||||
onSaveNote={handleSaveNote}
|
||||
|
|
@ -1629,6 +1632,7 @@ export function KProfilesView() {
|
|||
builtinFilaments={enrichedBuiltinFilaments}
|
||||
filamentPresets={filamentPresets}
|
||||
isDualNozzle={isDualNozzle}
|
||||
supportsFlowType={supportsFlowType}
|
||||
onSaveNote={handleSaveNote}
|
||||
hasPermission={hasPermission}
|
||||
onClose={() => {
|
||||
|
|
@ -1651,6 +1655,7 @@ export function KProfilesView() {
|
|||
builtinFilaments={enrichedBuiltinFilaments}
|
||||
filamentPresets={filamentPresets}
|
||||
isDualNozzle={isDualNozzle}
|
||||
supportsFlowType={supportsFlowType}
|
||||
onSaveNote={handleSaveNote}
|
||||
hasPermission={hasPermission}
|
||||
// Pass profile data but without slot_id to create a new profile
|
||||
|
|
|
|||
|
|
@ -5046,7 +5046,6 @@ export default {
|
|||
flowType: 'Flusstyp',
|
||||
highFlow: 'Hoher Durchfluss',
|
||||
standard: 'Standard',
|
||||
flowTypeNotReported: 'Vom Drucker nicht gemeldet',
|
||||
nozzleSize: 'Düsengröße',
|
||||
extruder: 'Extruder',
|
||||
extruders: 'Extruder',
|
||||
|
|
|
|||
|
|
@ -5090,7 +5090,6 @@ export default {
|
|||
flowType: 'Flow Type',
|
||||
highFlow: 'High Flow',
|
||||
standard: 'Standard',
|
||||
flowTypeNotReported: 'Not reported by printer',
|
||||
nozzleSize: 'Nozzle Size',
|
||||
extruder: 'Extruder',
|
||||
extruders: 'Extruders',
|
||||
|
|
|
|||
|
|
@ -5055,7 +5055,6 @@ export default {
|
|||
flowType: 'Tipo de flujo',
|
||||
highFlow: 'Flujo alto',
|
||||
standard: 'Estándar',
|
||||
flowTypeNotReported: 'No informado por la impresora',
|
||||
nozzleSize: 'Tamaño de la boquilla',
|
||||
extruder: 'Extrusor',
|
||||
extruders: 'Extrusores',
|
||||
|
|
|
|||
|
|
@ -5036,7 +5036,6 @@ export default {
|
|||
flowType: 'Type de débit',
|
||||
highFlow: 'Haut Débit (HF)',
|
||||
standard: 'Standard',
|
||||
flowTypeNotReported: 'Non communiqué par l\'imprimante',
|
||||
nozzleSize: 'Taille buse',
|
||||
extruder: 'Extrudeur',
|
||||
extruders: 'Extrudeurs',
|
||||
|
|
|
|||
|
|
@ -5035,7 +5035,6 @@ export default {
|
|||
flowType: 'Tipo flow',
|
||||
highFlow: 'Alto flusso',
|
||||
standard: 'Standard',
|
||||
flowTypeNotReported: 'Non riportato dalla stampante',
|
||||
nozzleSize: 'Dimensione ugello',
|
||||
extruder: 'Estrusore',
|
||||
extruders: 'Estrusori',
|
||||
|
|
|
|||
|
|
@ -5047,7 +5047,6 @@ export default {
|
|||
flowType: 'フロータイプ',
|
||||
highFlow: 'ハイフロー',
|
||||
standard: 'スタンダード',
|
||||
flowTypeNotReported: 'プリンターから報告なし',
|
||||
nozzleSize: 'ノズルサイズ',
|
||||
extruder: 'エクストルーダー',
|
||||
extruders: 'エクストルーダー',
|
||||
|
|
|
|||
|
|
@ -4790,7 +4790,6 @@ export default {
|
|||
flowType: '유량 유형',
|
||||
highFlow: '고유량',
|
||||
standard: '표준',
|
||||
flowTypeNotReported: '프린터에서 보고하지 않음',
|
||||
nozzleSize: '노즐 크기',
|
||||
extruder: '압출기',
|
||||
extruders: '압출기',
|
||||
|
|
|
|||
|
|
@ -5035,7 +5035,6 @@ export default {
|
|||
flowType: 'Tipo de Fluxo',
|
||||
highFlow: 'Alto Fluxo',
|
||||
standard: 'Padrão',
|
||||
flowTypeNotReported: 'Não informado pela impressora',
|
||||
nozzleSize: 'Tamanho do Bico',
|
||||
extruder: 'Extrusor',
|
||||
extruders: 'Extrusores',
|
||||
|
|
|
|||
|
|
@ -4778,7 +4778,6 @@ export default {
|
|||
flowType: "Тип потока",
|
||||
highFlow: "Высокопоточный",
|
||||
standard: "Стандартный",
|
||||
flowTypeNotReported: "Принтер не сообщает",
|
||||
nozzleSize: "Диаметр сопла",
|
||||
extruder: "Экструдер",
|
||||
extruders: "Экструдеры",
|
||||
|
|
|
|||
|
|
@ -5015,7 +5015,6 @@ export default {
|
|||
flowType: 'Akış Türü',
|
||||
highFlow: 'Yüksek Akış',
|
||||
standard: 'Standart',
|
||||
flowTypeNotReported: 'Yazıcı tarafından bildirilmedi',
|
||||
nozzleSize: 'Nozul Boyutu',
|
||||
extruder: 'Ekstrüder',
|
||||
extruders: 'Ekstrüderler',
|
||||
|
|
|
|||
|
|
@ -5090,7 +5090,6 @@ export default {
|
|||
flowType: "Тип потоку",
|
||||
highFlow: "Сопло з високим потоком",
|
||||
standard: "Стандартний",
|
||||
flowTypeNotReported: "Принтер не повідомляє",
|
||||
nozzleSize: "Розмір сопла",
|
||||
extruder: "Екструдер",
|
||||
extruders: "Екструдери",
|
||||
|
|
|
|||
|
|
@ -5035,7 +5035,6 @@ export default {
|
|||
flowType: '流量类型',
|
||||
highFlow: '高流量',
|
||||
standard: '标准',
|
||||
flowTypeNotReported: '打印机未报告',
|
||||
nozzleSize: '喷嘴尺寸',
|
||||
extruder: '挤出机',
|
||||
extruders: '挤出机',
|
||||
|
|
|
|||
|
|
@ -5035,7 +5035,6 @@ export default {
|
|||
flowType: '流量類型',
|
||||
highFlow: '高流量',
|
||||
standard: '標準',
|
||||
flowTypeNotReported: '印表機未回報',
|
||||
nozzleSize: '噴嘴尺寸',
|
||||
extruder: '擠出機',
|
||||
extruders: '擠出機',
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-E-CRp_kM.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-D27DV9N0.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C_6BSgrK.css">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue