Selaa lähdekoodia

fix(kprofiles): an AMS change leaves the K-profile to the lost-selection check; command answers no longer change the nozzle; unconfirmed writes say so and edits read back; skip refusals are logged; stop-drying toast says sent

maziggy 4 päivää sitten
vanhempi
sitoutus
f45cc599f3
100 muutettua tiedostoa jossa 619 lisäystä ja 117 poistoa
  1. 5 0
      CHANGELOG.md
  2. 46 4
      backend/app/api/routes/kprofiles.py
  3. 4 82
      backend/app/main.py
  4. 76 2
      backend/app/services/bambu_mqtt.py
  5. 116 0
      backend/tests/integration/test_ams_change_kprofile_reapply_44.py
  6. 147 0
      backend/tests/integration/test_kprofile_save_confirmation_g7.py
  7. 24 0
      backend/tests/unit/test_kprofile_ack_state_g7.py
  8. 85 0
      backend/tests/unit/test_skip_objects_confirmation_g7.py
  9. 2 1
      frontend/src/__tests__/pages/PrintersPageDryingFeedback.test.tsx
  10. 22 0
      frontend/src/__tests__/utils/kprofileWrite.test.ts
  11. 4 3
      frontend/src/api/client.ts
  12. 16 6
      frontend/src/components/KProfilesView.tsx
  13. 3 1
      frontend/src/i18n/locales/de.ts
  14. 3 1
      frontend/src/i18n/locales/en.ts
  15. 3 1
      frontend/src/i18n/locales/es.ts
  16. 3 1
      frontend/src/i18n/locales/fr.ts
  17. 3 1
      frontend/src/i18n/locales/it.ts
  18. 3 1
      frontend/src/i18n/locales/ja.ts
  19. 3 1
      frontend/src/i18n/locales/ko.ts
  20. 3 1
      frontend/src/i18n/locales/nl.ts
  21. 3 1
      frontend/src/i18n/locales/pt-BR.ts
  22. 3 1
      frontend/src/i18n/locales/ru.ts
  23. 3 1
      frontend/src/i18n/locales/sv.ts
  24. 3 1
      frontend/src/i18n/locales/tr.ts
  25. 3 1
      frontend/src/i18n/locales/uk.ts
  26. 3 1
      frontend/src/i18n/locales/zh-CN.ts
  27. 3 1
      frontend/src/i18n/locales/zh-TW.ts
  28. 25 0
      frontend/src/utils/kprofileWrite.ts
  29. 0 0
      static/assets/AdditionalSection-L-15MXNk.js
  30. 0 0
      static/assets/AmsUnitCard-CwBsWxvt.js
  31. 0 0
      static/assets/ArchivesPage-B4hbjTm5.js
  32. 0 0
      static/assets/AssignToAmsModal-BAA9H884.js
  33. 0 0
      static/assets/CamWallPage-B90Vk8W7.js
  34. 0 0
      static/assets/CameraDiagnoseModal-CXD2FTZY.js
  35. 0 0
      static/assets/CameraPage-DscTlQvs.js
  36. 0 0
      static/assets/CameraWall-XQqfi-uc.js
  37. 0 0
      static/assets/ColumnConfigModal-WzRBT7eu.js
  38. 0 0
      static/assets/ConfigureAmsSlotModal-DC9_cLDm.js
  39. 0 0
      static/assets/ConnectAuthorizePage-CQUx1amq.js
  40. 0 0
      static/assets/ContextMenu-BiM9sjmK.js
  41. 0 0
      static/assets/ExternalLinkPage-CSVHYlAP.js
  42. 0 1
      static/assets/FileManagerPage-DXKdNPDt.js
  43. 0 0
      static/assets/FileUploadModal-BsT6a9pv.js
  44. 0 0
      static/assets/FinancePage-Ltm_FK79.js
  45. 0 0
      static/assets/GCodeViewerPage-MLPD1B1H.js
  46. 0 0
      static/assets/GroupEditPage-C3l2le7H.js
  47. 0 0
      static/assets/ImagePreviewModal-BF0BlhBB.js
  48. 0 0
      static/assets/InventoryPage-DjaDMrPH.js
  49. 0 0
      static/assets/LibraryTrashPage-BusrKlwO.js
  50. 0 0
      static/assets/LoginPage-DNnkmXTo.js
  51. 0 0
      static/assets/MaintenancePage-07IZyhJ2.js
  52. 0 0
      static/assets/ModelSourcesPage-BSGlDWqx.js
  53. 0 0
      static/assets/NotificationsPage-Ccsx2PK-.js
  54. 0 1
      static/assets/PdfPreviewModal-DNCJdhP9.js
  55. 0 0
      static/assets/PhotoGalleryModal-BRQ7dkyl.js
  56. 0 0
      static/assets/PrinterLocationsPage-nsZN50k7.js
  57. 0 0
      static/assets/PrintersPage-CdUF8kSL.js
  58. 0 0
      static/assets/ProfilesPage-Blrzv8Jz.js
  59. 0 0
      static/assets/ProfilesPage-BuIp6mUb.js
  60. 0 0
      static/assets/ProjectDetailPage-B49WcQ1V.js
  61. 0 0
      static/assets/ProjectsPage-CmcOz5CS.js
  62. 0 0
      static/assets/QueuePage-C3qqdE-w.js
  63. 0 0
      static/assets/RichTextEditor-sa9WxUkB.js
  64. 0 0
      static/assets/SettingsPage-CqoOyPKy.js
  65. 0 0
      static/assets/SetupPage-uDTUZ0WY.js
  66. 0 0
      static/assets/SliceModal-BWbv4zZ_.js
  67. 0 0
      static/assets/SpoolBuddyAmsPage-ChteKbX4.js
  68. 0 0
      static/assets/SpoolBuddyDashboard-nxR-lH3x.js
  69. 0 0
      static/assets/SpoolBuddyInventoryPage-Cy-CoWOm.js
  70. 1 1
      static/assets/SpoolBuddySettingsPage-Dr-7pM8b.js
  71. 0 0
      static/assets/SpoolBuddyWriteTagPage-B4TAT5Wj.js
  72. 0 0
      static/assets/SpreadsheetPreviewModal-BIpa5wgb.js
  73. 0 0
      static/assets/StatsPage-CZjXoczc.js
  74. 0 0
      static/assets/StreamOverlayPage-By0n8WvQ.js
  75. 0 0
      static/assets/SystemInfoPage-jNhmkglP.js
  76. 0 0
      static/assets/de-BqH_BwEQ.js
  77. 0 0
      static/assets/de-cAJU3vlP.js
  78. 0 0
      static/assets/es-DMnPhPHh.js
  79. 0 0
      static/assets/es-Lnnz1GCC.js
  80. 0 0
      static/assets/fr-4FImw2fq.js
  81. 0 0
      static/assets/fr-DEhIPvJF.js
  82. 0 0
      static/assets/index-BQS5hDzk.js
  83. 1 1
      static/assets/inventorySearch-BMYQ29xO.js
  84. 0 0
      static/assets/it-B1z0Al3p.js
  85. 0 0
      static/assets/it-BvQDa5wf.js
  86. 0 0
      static/assets/ja-B2cLeNaQ.js
  87. 0 0
      static/assets/ja-DitLMdvB.js
  88. 0 0
      static/assets/ko-B2JMT7Xl.js
  89. 0 0
      static/assets/ko-Dwk4TaGQ.js
  90. 0 0
      static/assets/locationSensorDefaults-izjsR7C8.js
  91. 0 0
      static/assets/nl-BMI9lZx8.js
  92. 0 0
      static/assets/nl-BqAdEb0Z.js
  93. 0 0
      static/assets/pdf-8QJQv9CU.js
  94. 0 0
      static/assets/pt-BR-Bwas9MuF.js
  95. 0 0
      static/assets/pt-BR-GJJmaAhf.js
  96. 0 0
      static/assets/ru-Bzgmtaam.js
  97. 0 0
      static/assets/ru-Cy93M7Z2.js
  98. 0 0
      static/assets/sv-DDESJEYV.js
  99. 0 0
      static/assets/sv-zbZ8xp6l.js
  100. 0 0
      static/assets/tr-Ch77jSWu.js

+ 5 - 0
CHANGELOG.md

@@ -164,6 +164,11 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **A K-profile chosen on purpose is no longer undone when the AMS reports a change** — When a spool with a stored K-profile sat in a slot, an AMS report that changed the slot (its remaining amount, for example) set it back to the stored profile, so one picked in Bambu Studio or on the printer's screen did not last. That re-apply is gone: the existing lost-profile check puts the stored profile back when the slot has lost its selection (the default K after a reset or power cycle) while the printer is idle, and leaves a deliberately chosen profile, a chosen **Default** and the other nozzle's calibration alone.
+- **K-profile commands no longer change the shown nozzle size** — The printer's answer to saving, deleting or selecting a K-profile names the nozzle size of that profile, and Bambuddy took it as the installed nozzle. Only the printer's status updates the nozzle now.
+- **A K-profile save says when the printer did not confirm it** — A save, batch save or delete the printer did not acknowledge was reported as done. It now says the profile was sent but not confirmed (older firmware never answers), and an import or bulk delete says how many of its writes went unconfirmed. An edit on a single-nozzle printer, which removes the profile and adds it again, reads the printer's table back and fails when the edited profile is not there.
+- **Stopping drying says the command was sent** — The toast said "Drying stopped" as soon as the command went out; it now says "Stop command sent".
+- **A skip the printer refuses or never applies is logged** — Skipped objects were marked skipped the moment the command was sent. A refusal from the printer now takes them back off the skipped list and is logged with the printer's reason, and a skip the printer has not listed after a minute is logged as unconfirmed.
 - **The printer card shows a nozzle change, firmware update or developer-mode switch at once** — These were left out of the live updates and only reached the card with the 30-second refresh or a page reload.
 - **The print slot labels on AMS slots show up** — While a print runs, each AMS slot the print uses is meant to carry a small "P1 / P2" label, so a job sent to a printer with the wrong filament stands out. The labels read the print's mapping from the printer's own status, which almost never carries it, so they rarely appeared. Bambuddy already records the mapping from the print command, and the labels now use it. A print started from the printer's screen after a job that never started (refused, or cancelled before printing) no longer inherits that job's mapping, which usage tracking read as well.
 - **Drying no longer offers spool rotation on an AMS with filament in the feed tube** — **Rotate spool during drying** was disabled only for the tray state that means "loaded" on some firmware, so on others it stayed available and the printer refused the drying start. The slot the printer reports as loaded, and on two-nozzle printers the slot each nozzle is fed from, now count too.

+ 46 - 4
backend/app/api/routes/kprofiles.py

@@ -21,6 +21,7 @@ from backend.app.schemas.kprofile import (
     KProfileNoteResponse,
     KProfilesResponse,
 )
+from backend.app.services.bambu_mqtt import CALI_ACK_UNCONFIRMED
 from backend.app.services.printer_manager import printer_manager
 
 logger = logging.getLogger(__name__)
@@ -120,6 +121,8 @@ async def set_kprofile(
     from backend.app.utils.printer_models import is_dual_nozzle_model
 
     is_dual_nozzle = client._is_dual_nozzle or is_dual_nozzle_model(printer.model)
+    # Whether the printer answered the delete of a delete-and-add edit.
+    delete_confirmed = True
 
     if is_edit and is_dual_nozzle:
         # Dual-nozzle in-place edit: use cali_idx with slot_id=0 and empty setting_id
@@ -151,6 +154,7 @@ async def set_kprofile(
         ok, detail = await client.await_cali_ack(delete_success)
         if not ok:
             raise HTTPException(500, f"Printer rejected the K-profile edit: {detail}")
+        delete_confirmed = detail != CALI_ACK_UNCONFIRMED
 
         # Wait for printer to process the delete before adding
         await asyncio.sleep(0.5)
@@ -188,9 +192,43 @@ async def set_kprofile(
     ok, detail = await client.await_cali_ack(success)
     if not ok:
         raise HTTPException(500, f"Printer rejected the K-profile: {detail}")
+    confirmed = detail != CALI_ACK_UNCONFIRMED
 
-    message = "K-profile updated successfully" if is_edit else "K-profile added successfully"
-    return {"success": True, "message": message}
+    if is_edit and not is_dual_nozzle:
+        # The edit deleted the profile and added it again: read the table back
+        # to see the edited one landed. An empty table is also what a read that
+        # timed out returns, so only a table without it is proof. Same pause as
+        # before the add.
+        await asyncio.sleep(0.5)
+        table = await client.get_kprofiles(nozzle_diameter=profile.nozzle_diameter)
+        if table:
+            if not any(_is_saved_profile(stored, profile) for stored in table):
+                raise HTTPException(
+                    502,
+                    "The edited K-profile is not in the printer's table. Check the list before adding it again.",
+                )
+            # The old one may still be there when its delete went unanswered.
+            confirmed = delete_confirmed
+
+    if not confirmed:
+        message = "K-profile sent; the printer did not confirm it"
+    else:
+        message = "K-profile updated successfully" if is_edit else "K-profile added successfully"
+    return {"success": True, "confirmed": confirmed, "message": message}
+
+
+def _is_saved_profile(stored, profile: KProfileCreate) -> bool:
+    """Whether a profile read from the printer is the one just written."""
+    try:
+        same_k = abs(float(stored.k_value) - float(profile.k_value)) < 1e-6
+    except (TypeError, ValueError):
+        return False
+    return (
+        same_k
+        and stored.name == profile.name
+        and stored.filament_id == profile.filament_id
+        and stored.extruder_id == profile.extruder_id
+    )
 
 
 @router.post("/batch", response_model=dict)
@@ -253,7 +291,11 @@ async def set_kprofiles_batch(
     if not ok:
         raise HTTPException(500, f"Printer rejected the K-profiles: {detail}")
 
-    return {"success": True, "message": f"Added {len(profiles)} K-profiles"}
+    return {
+        "success": True,
+        "confirmed": detail != CALI_ACK_UNCONFIRMED,
+        "message": f"Added {len(profiles)} K-profiles",
+    }
 
 
 @router.delete("/", response_model=dict)
@@ -304,7 +346,7 @@ async def delete_kprofile(
     # Wait for printer to process the delete before frontend refetches
     await asyncio.sleep(0.5)
 
-    return {"success": True, "message": "K-profile deleted successfully"}
+    return {"success": True, "confirmed": detail != CALI_ACK_UNCONFIRMED, "message": "K-profile deleted successfully"}
 
 
 @router.get("/notes", response_model=KProfileNoteResponse)

+ 4 - 82
backend/app/main.py

@@ -151,7 +151,6 @@ from backend.app.services.slot_kprofile import find_slot_kprofile_for_extruder
 from backend.app.services.slot_nozzle import (
     nozzle_diameter_for_extruder,
     nozzle_flow_for_extruder,
-    resolve_slot_nozzle,
 )
 from backend.app.services.smart_plug_manager import smart_plug_manager
 from backend.app.services.spool_assignment_notifications import (
@@ -3023,87 +3022,10 @@ async def on_ams_change(printer_id: int, ams_data: list):
                                         existing_assignment.spool.weight_used = new_used
                                         await db.commit()
 
-                            # Re-apply stored K-profile when the live tray's
-                            # cali_idx drifted from the spool's stored profile.
-                            # This catches "reset slot → re-read" and any other
-                            # path where the firmware loses the user's K-profile
-                            # selection while the SpoolAssignment row persists.
-                            # Per the maintainer's rule: any time a spool tag is
-                            # identified and matches inventory, the slot must be
-                            # configured with the spool's stored settings. Without
-                            # this block the existing-assignment branch only ran
-                            # weight-sync and let the firmware-default cali_idx win.
-                            try:
-                                spool = existing_assignment.spool
-                                if (
-                                    spool is not None
-                                    and is_bambu_tag(tag_uid, tray_uuid, tray_info_idx)
-                                    and spool.k_profiles
-                                ):
-                                    state = printer_manager.get_status(printer_id)
-                                    slot_nozzle = resolve_slot_nozzle(
-                                        state, ams_id, tray_id, printer_manager.get_model(printer_id)
-                                    )
-                                    nozzle_diameter = slot_nozzle.diameter
-                                    slot_extruder = slot_nozzle.extruder
-                                    # Prefer exact extruder match, fall back to
-                                    # extruder-agnostic kp for the same printer +
-                                    # nozzle. Avoids hard-skipping when the AMS is
-                                    # mapped differently than at calibration time.
-                                    matching_kp = None
-                                    fallback_kp = None
-                                    for kp in spool.k_profiles:
-                                        if (
-                                            kp.printer_id != printer_id
-                                            or kp.nozzle_diameter != nozzle_diameter
-                                            or kp.cali_idx is None
-                                            or not slot_nozzle.flow_matches(kp.nozzle_type)
-                                        ):
-                                            continue
-                                        if (
-                                            slot_extruder is not None
-                                            and kp.extruder is not None
-                                            and kp.extruder == slot_extruder
-                                        ):
-                                            matching_kp = kp
-                                            break
-                                        if fallback_kp is None:
-                                            fallback_kp = kp
-                                    chosen_kp = matching_kp or fallback_kp
-                                    if chosen_kp is not None:
-                                        live_cali_idx = tray.get("cali_idx")
-                                        # Only fire MQTT when the printer's live
-                                        # cali_idx differs from the stored value.
-                                        # Avoids spamming the broker on every
-                                        # MQTT push during steady-state operation.
-                                        if live_cali_idx != chosen_kp.cali_idx:
-                                            client = printer_manager.get_client(printer_id)
-                                            if client:
-                                                cali_filament_id = spool.slicer_filament or tray_info_idx or ""
-                                                client.extrusion_cali_sel(
-                                                    ams_id=ams_id,
-                                                    tray_id=tray_id,
-                                                    cali_idx=chosen_kp.cali_idx,
-                                                    filament_id=cali_filament_id,
-                                                    nozzle_diameter=nozzle_diameter,
-                                                )
-                                                logger.info(
-                                                    "Re-applied K-profile cali_idx=%d for spool %d "
-                                                    "on printer %d AMS%d-T%d (live=%s drift detected)",
-                                                    chosen_kp.cali_idx,
-                                                    spool.id,
-                                                    printer_id,
-                                                    ams_id,
-                                                    tray_id,
-                                                    live_cali_idx,
-                                                )
-                            except Exception:
-                                logger.exception(
-                                    "K-profile re-apply failed for printer %d AMS%d-T%d",
-                                    printer_id,
-                                    ams_id,
-                                    tray_id,
-                                )
+                            # A K-profile the slot lost (the default K after a reset or
+                            # power cycle) is put back by kprofile_drift on the next idle
+                            # push; a different real profile was picked on purpose and
+                            # stays (#3219, item 44).
                             continue
 
                         if is_bambu_tag(tag_uid, tray_uuid, tray_info_idx):

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

@@ -842,6 +842,21 @@ class NozzleInfo:
     nozzle_diameter: str = ""  # e.g., "0.4"
 
 
+# What await_cali_ack reports when the printer did not answer a K-profile write
+# in time. Not a refusal: firmware that predates the ack never answers.
+CALI_ACK_UNCONFIRMED = "no acknowledgement from printer"
+
+# Time the printer has to list a skipped object in s_obj before the skip is
+# logged as not confirmed.
+SKIP_CONFIRM_TIMEOUT_S = 60.0
+
+# Answers to K-profile commands: they echo the command's own fields (the nozzle
+# diameter among them), never the printer's status.
+_KPROFILE_COMMAND_ANSWERS = frozenset(
+    {"extrusion_cali_get", "extrusion_cali_set", "extrusion_cali_del", "extrusion_cali_sel"}
+)
+
+
 @dataclass
 class FilaSwitchState:
     """Filament Track Switch (FTS) accessory state.
@@ -1504,6 +1519,11 @@ class BambuMQTTClient:
         # 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] = {}
+        # Objects sent in skip_objects that the printer's s_obj list has not
+        # shown yet: object id -> when it was sent. And the ids of each
+        # command by its sequence_id, for its answer.
+        self._unconfirmed_skips: dict[int, float] = {}
+        self._skip_commands: dict[str, list[int]] = {}
         # Acks for RFID re-reads (ams_get_rfid, and the M620 R gcode_line
         # fallback), keyed the same way. Drained by ams_refresh_tray (#3206).
         self._pending_rfid_acks: dict[str, dict | None] = {}
@@ -2519,6 +2539,8 @@ class BambuMQTTClient:
                     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 == "skip_objects":
+                    self._handle_skip_objects_answer(print_data)
                 elif cmd in ("extrusion_cali_sel", "ams_filament_setting"):
                     logger.debug("[%s] %s response: %s", self.serial_number, cmd, print_data)
                     # A refused ams_filament_setting is the printer's verdict on
@@ -2618,7 +2640,9 @@ class BambuMQTTClient:
             # which then failed the #1899 dispatch guard. The response carries no
             # status telemetry, so skip it; the true nozzle comes from pushall.
             # (Same reasoning as get_accessories in _handle_system_response.)
-            if not is_kprofile_response:
+            # The answers to K-profile writes and selections echo the diameter
+            # the command named in the same way, so none of them is status.
+            if print_data.get("command") not in _KPROFILE_COMMAND_ANSWERS:
                 self._update_state(print_data)
 
     def _handle_system_response(self, data: dict):
@@ -5331,6 +5355,9 @@ class BambuMQTTClient:
                 if new_skipped != self.state.skipped_objects:
                     logger.debug("[%s] skipped_objects updated from printer: %s", self.serial_number, new_skipped)
                     self.state.skipped_objects = new_skipped
+                for oid in new_skipped:
+                    self._unconfirmed_skips.pop(oid, None)
+        self._check_unconfirmed_skips()
 
         # Parse chamber light status from lights_report
         if "lights_report" in data:
@@ -7094,7 +7121,7 @@ class BambuMQTTClient:
         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")
+        return (True, CALI_ACK_UNCONFIRMED)
 
     def set_kprofile(
         self,
@@ -7372,6 +7399,49 @@ class BambuMQTTClient:
         logger.info("[%s] Sent clear HMS errors command", self.serial_number)
         return True
 
+    def _handle_skip_objects_answer(self, print_data: dict) -> None:
+        """The printer's answer to skip_objects: a refusal is logged and the
+        objects are no longer shown as skipped."""
+        obj_list = self._skip_commands.pop(str(print_data.get("sequence_id", "")), None)
+        result = str(print_data.get("result", "") or "").lower()
+        if obj_list is None or result in ("", "success"):
+            return
+        logger.error(
+            "[%s] Printer refused skipping objects %s: result=%s reason=%s",
+            self.serial_number,
+            obj_list,
+            print_data.get("result"),
+            print_data.get("reason", ""),
+        )
+        for oid in obj_list:
+            self._unconfirmed_skips.pop(oid, None)
+        self.state.skipped_objects = [oid for oid in self.state.skipped_objects if oid not in obj_list]
+
+    def _check_unconfirmed_skips(self) -> None:
+        """Log, once, a skip the printer's s_obj list never showed."""
+        if not self._unconfirmed_skips:
+            return
+        if self.state.state not in ("RUNNING", "PAUSE"):
+            # The print ended; its skips are moot.
+            self._unconfirmed_skips.clear()
+            self._skip_commands.clear()
+            return
+        now = time.monotonic()
+        # A copy: skip_objects adds to the dict from the event loop while this
+        # runs on the MQTT thread.
+        pending = list(self._unconfirmed_skips.items())
+        expired = [oid for oid, sent_at in pending if now - sent_at > SKIP_CONFIRM_TIMEOUT_S]
+        if not expired:
+            return
+        logger.error(
+            "[%s] Printer did not confirm skipping objects %s within %.0fs; they may still be printed",
+            self.serial_number,
+            sorted(expired),
+            SKIP_CONFIRM_TIMEOUT_S,
+        )
+        for oid in expired:
+            self._unconfirmed_skips.pop(oid, None)
+
     def skip_objects(self, object_ids: list[int]) -> bool:
         """Skip specific objects during a print.
 
@@ -7409,6 +7479,10 @@ class BambuMQTTClient:
         command = {"print": {"sequence_id": str(self._sequence_id), "command": "skip_objects", "obj_list": obj_list}}
         self._client.publish(self.topic_publish, json.dumps(command), qos=1)
         logger.info("[%s] Sent skip_objects command: %s", self.serial_number, obj_list)
+        sent_at = time.monotonic()
+        for oid in obj_list:
+            self._unconfirmed_skips.setdefault(oid, sent_at)
+        self._skip_commands[str(self._sequence_id)] = obj_list
 
         # Track skipped objects in state
         for oid in obj_list:

+ 116 - 0
backend/tests/integration/test_ams_change_kprofile_reapply_44.py

@@ -0,0 +1,116 @@
+"""An AMS change no longer re-selects a spool's stored K-profile itself.
+
+It used to set the slot back to the stored profile whenever its cali_idx
+differed, so a profile picked on purpose in Bambu Studio or on the printer's
+screen was undone by the next AMS report that changed the slot. The #3219
+drift check already puts back a *lost* selection (cali_idx -1 or missing)
+while the printer is idle, and leaves a different real profile, a deliberate
+"Default" and the other hotend's calibration alone. It is now the only path.
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from backend.app.models.spool import Spool
+from backend.app.models.spool_assignment import SpoolAssignment
+from backend.app.models.spool_k_profile import SpoolKProfile
+from backend.app.services import kprofile_drift
+from backend.app.services.usage_tracker import _active_sessions
+
+TAG_UID = "A1B2C3D4E5F60708"
+TRAY_UUID = "0123456789ABCDEF0123456789ABCDEF"
+
+
+@pytest.fixture(autouse=True)
+def _no_sessions():
+    _active_sessions.clear()
+    yield
+    _active_sessions.clear()
+
+
+async def _ams_push(test_engine, db_session, printer_factory, *, live_cali_idx, state="IDLE") -> MagicMock:
+    from backend.app.main import on_ams_change
+
+    printer = await printer_factory(name="X1C")
+    spool = Spool(material="PLA", label_weight=1000, weight_used=0, tag_uid=TAG_UID, tray_uuid=TRAY_UUID)
+    db_session.add(spool)
+    await db_session.flush()
+    db_session.add(
+        SpoolKProfile(
+            spool_id=spool.id, printer_id=printer.id, extruder=0, nozzle_diameter="0.4", k_value=0.02, cali_idx=7
+        )
+    )
+    db_session.add(
+        SpoolAssignment(
+            spool_id=spool.id,
+            printer_id=printer.id,
+            ams_id=0,
+            tray_id=0,
+            fingerprint_color="FF0000FF",
+            fingerprint_type="PLA",
+        )
+    )
+    await db_session.commit()
+
+    tray = {
+        "id": 0,
+        "tray_type": "PLA",
+        "tray_color": "FF0000FF",
+        "tag_uid": TAG_UID,
+        "tray_uuid": TRAY_UUID,
+        "tray_info_idx": "GFA00",
+        "remain": -1,
+    }
+    if live_cali_idx is not None:
+        tray["cali_idx"] = live_cali_idx
+    ams_data = [{"id": 0, "tray": [tray]}]
+    status = MagicMock()
+    status.raw_data = {"ams": {"ams": ams_data}}
+    status.state = state
+    status.connected = True
+    client = MagicMock()
+
+    real_db = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+    with (
+        patch("backend.app.main.async_session", real_db),
+        patch("backend.app.core.database.async_session", real_db),
+        patch("backend.app.main.printer_manager") as mock_pm,
+        patch("backend.app.main.mqtt_relay") as mock_relay,
+        patch("backend.app.main.ws_manager") as mock_ws,
+    ):
+        mock_pm.get_printer.return_value = MagicMock(name="X1C", serial_number="KREAPPLY")
+        mock_pm.get_status.return_value = status
+        mock_pm.get_client.return_value = client
+        mock_pm.get_model.return_value = "X1C"
+        mock_relay.on_ams_change = AsyncMock()
+        mock_ws.send_printer_status = AsyncMock()
+        mock_ws.broadcast = AsyncMock()
+
+        await on_ams_change(printer.id, ams_data)
+
+    return client
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+@pytest.mark.parametrize("live", [-1, None, 3])
+async def test_the_ams_change_leaves_the_selection_to_the_drift_check(test_engine, db_session, printer_factory, live):
+    client = await _ams_push(test_engine, db_session, printer_factory, live_cali_idx=live)
+
+    client.extrusion_cali_sel.assert_not_called()
+
+
+@pytest.mark.parametrize(("live", "picked_up"), [(-1, True), (None, True), (3, False)])
+def test_the_drift_check_picks_up_only_a_lost_selection(live, picked_up):
+    kprofile_drift._watches.clear()
+    tray = {"id": 0, "tray_type": "PLA"}
+    if live is not None:
+        tray["cali_idx"] = live
+    state = MagicMock(connected=True, state="IDLE", raw_data={"ams": [{"id": 0, "tray": [tray]}]})
+
+    try:
+        assert kprofile_drift.needs_check(4242, state) is picked_up
+    finally:
+        kprofile_drift._watches.clear()

+ 147 - 0
backend/tests/integration/test_kprofile_save_confirmation_g7.py

@@ -0,0 +1,147 @@
+"""A K-profile save says whether the printer confirmed it.
+
+The printer acknowledges K-profile writes, but firmware that predates the ack
+never answers, so a missing answer is not a refusal. The routes reported such a
+write as saved all the same. They now say it was sent and not confirmed.
+
+An edit on a single-nozzle printer deletes the profile and adds it again: if
+the add did not land, the profile is gone. That edit now reads the table back.
+"""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.services.bambu_mqtt import CALI_ACK_UNCONFIRMED, KProfile
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+PROFILE = {
+    "slot_id": 0,
+    "extruder_id": 0,
+    "nozzle_id": "HS00-0.4",
+    "nozzle_diameter": "0.4",
+    "filament_id": "GFA00",
+    "name": "Bambu PLA Basic",
+    "k_value": "0.020000",
+}
+
+
+def _client(ack, table=None):
+    client = MagicMock()
+    client.state = SimpleNamespace(connected=True)
+    client._is_dual_nozzle = False
+    client.set_kprofile.return_value = "11"
+    client.delete_kprofile.return_value = "10"
+    client.set_kprofiles_batch.return_value = "12"
+    client.await_cali_ack = AsyncMock(return_value=ack)
+    client.get_kprofiles = AsyncMock(return_value=table or [])
+    return client
+
+
+def _stored(**overrides) -> KProfile:
+    fields = {
+        "slot_id": 4,
+        "extruder_id": 0,
+        "nozzle_id": "HS00-0.4",
+        "nozzle_diameter": "0.4",
+        "filament_id": "GFA00",
+        "name": "Bambu PLA Basic",
+        "k_value": "0.020000",
+    }
+    fields.update(overrides)
+    return KProfile(**fields)
+
+
+async def _post(async_client: AsyncClient, printer_factory, client, path="", body=None, method="post"):
+    printer = await printer_factory(model="X1C")
+    with (
+        patch("backend.app.api.routes.kprofiles.printer_manager") as pm,
+        patch("backend.app.api.routes.kprofiles.asyncio.sleep", AsyncMock()),
+    ):
+        pm.get_client.return_value = client
+        url = f"/api/v1/printers/{printer.id}/kprofiles/{path}"
+        if method == "delete":
+            return await async_client.request("DELETE", url, json=body)
+        return await async_client.post(url, json=body)
+
+
+async def test_a_confirmed_save_says_so(async_client, printer_factory):
+    response = await _post(async_client, printer_factory, _client((True, "")), body=PROFILE)
+
+    assert response.status_code == 200
+    assert response.json()["confirmed"] is True
+
+
+async def test_a_save_the_printer_did_not_answer_is_unconfirmed(async_client, printer_factory):
+    response = await _post(async_client, printer_factory, _client((True, CALI_ACK_UNCONFIRMED)), body=PROFILE)
+
+    assert response.status_code == 200
+    assert response.json()["confirmed"] is False
+
+
+@pytest.mark.parametrize(
+    ("path", "body", "method"),
+    [
+        ("batch", [PROFILE], "post"),
+        ("", {**PROFILE, "slot_id": 4, "setting_id": None}, "delete"),
+    ],
+)
+async def test_batch_and_delete_say_it_too(async_client, printer_factory, path, body, method):
+    response = await _post(
+        async_client, printer_factory, _client((True, CALI_ACK_UNCONFIRMED)), path=path, body=body, method=method
+    )
+
+    assert response.status_code == 200
+    assert response.json()["confirmed"] is False
+
+
+class TestASingleNozzleEditIsReadBack:
+    EDIT = {**PROFILE, "slot_id": 4, "k_value": "0.025000"}
+
+    async def test_the_edited_profile_in_the_table_confirms_it(self, async_client, printer_factory):
+        client = _client((True, ""), table=[_stored(slot_id=5, k_value="0.025000")])
+        # The delete was answered, the add was not: the table settles it.
+        client.await_cali_ack = AsyncMock(side_effect=[(True, ""), (True, CALI_ACK_UNCONFIRMED)])
+
+        response = await _post(async_client, printer_factory, client, body=self.EDIT)
+
+        assert response.status_code == 200
+        assert response.json()["confirmed"] is True
+
+    async def test_a_lost_profile_is_an_error(self, async_client, printer_factory):
+        """The delete landed, the add did not: the table has other profiles but not this one."""
+        client = _client((True, ""), table=[_stored(slot_id=2, name="Other", filament_id="GFB00")])
+
+        response = await _post(async_client, printer_factory, client, body=self.EDIT)
+
+        assert response.status_code == 502
+        assert "not in the printer's table" in response.json()["detail"]
+
+    async def test_the_old_value_still_in_the_table_is_an_error(self, async_client, printer_factory):
+        client = _client((True, ""), table=[_stored(slot_id=4, k_value="0.020000")])
+
+        response = await _post(async_client, printer_factory, client, body=self.EDIT)
+
+        assert response.status_code == 502
+
+    async def test_an_unreadable_table_leaves_it_to_the_ack(self, async_client, printer_factory):
+        """An empty answer is also what a read that timed out returns."""
+        client = _client((True, CALI_ACK_UNCONFIRMED), table=[])
+
+        response = await _post(async_client, printer_factory, client, body=self.EDIT)
+
+        assert response.status_code == 200
+        assert response.json()["confirmed"] is False
+
+    async def test_an_unanswered_delete_leaves_it_unconfirmed(self, async_client, printer_factory):
+        """The edited profile landed, but the old one may still be there too."""
+        client = _client((True, ""), table=[_stored(slot_id=5, k_value="0.025000")])
+        client.await_cali_ack = AsyncMock(side_effect=[(True, CALI_ACK_UNCONFIRMED), (True, "")])
+
+        response = await _post(async_client, printer_factory, client, body=self.EDIT)
+
+        assert response.status_code == 200
+        assert response.json()["confirmed"] is False

+ 24 - 0
backend/tests/unit/test_kprofile_ack_state_g7.py

@@ -0,0 +1,24 @@
+"""A K-profile command's answer carries no printer status.
+
+The printer echoes extrusion_cali_set / _del / _sel with the nozzle diameter
+the command named. Only the extrusion_cali_get answer was kept out of the
+status update (#2663), so the others could overwrite the installed nozzle's
+diameter with whatever size the profile was written for.
+"""
+
+import pytest
+
+from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+
+@pytest.mark.parametrize("command", ["extrusion_cali_set", "extrusion_cali_del", "extrusion_cali_sel"])
+def test_the_answer_leaves_the_nozzle_alone(command):
+    client = BambuMQTTClient(ip_address="10.0.0.9", serial_number="SN-G7", access_code="x", model="X1C")
+    client._process_message({"print": {"command": "push_status", "nozzle_diameter": "0.4"}})
+    assert client.state.nozzles[0].nozzle_diameter == "0.4"
+
+    client._process_message(
+        {"print": {"command": command, "sequence_id": "9", "result": "success", "nozzle_diameter": "0.8"}}
+    )
+
+    assert client.state.nozzles[0].nozzle_diameter == "0.4"

+ 85 - 0
backend/tests/unit/test_skip_objects_confirmation_g7.py

@@ -0,0 +1,85 @@
+"""Skipping objects is confirmed by the printer, or the log says it was not.
+
+skip_objects marked the objects skipped as soon as the command was published
+and never read the answer. Only the printer's own s_obj list corrected that,
+and nothing recorded a skip the printer refused or never applied.
+"""
+
+import logging
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from backend.app.services import bambu_mqtt
+from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+
+@pytest.fixture
+def client():
+    c = BambuMQTTClient(ip_address="10.0.0.9", serial_number="SN-SKIP", access_code="x", model="X1C")
+    c._client = MagicMock()
+    c.state.connected = True
+    c.state.state = "RUNNING"
+    return c
+
+
+def _sent_seq(c) -> str:
+    import json
+
+    return json.loads(c._client.publish.call_args.args[1])["print"]["sequence_id"]
+
+
+def test_a_refusal_is_logged_and_undone(client, caplog):
+    assert client.skip_objects([5, 6])
+    seq = _sent_seq(client)
+
+    with caplog.at_level(logging.ERROR):
+        client._process_message(
+            {"print": {"command": "skip_objects", "sequence_id": seq, "result": "fail", "reason": "busy"}}
+        )
+
+    assert client.state.skipped_objects == []
+    assert any("refused" in r.message and "busy" in r.message for r in caplog.records)
+
+
+def test_the_printers_list_confirms_it(client, caplog):
+    with patch.object(bambu_mqtt.time, "monotonic", return_value=1_000.0):
+        client.skip_objects([5])
+        client._process_message({"print": {"command": "push_status", "s_obj": [5]}})
+
+    with (
+        patch.object(bambu_mqtt.time, "monotonic", return_value=1_000.0 + bambu_mqtt.SKIP_CONFIRM_TIMEOUT_S + 1),
+        caplog.at_level(logging.ERROR),
+    ):
+        client._process_message({"print": {"command": "push_status", "mc_percent": 50}})
+
+    assert not [r for r in caplog.records if r.levelno >= logging.ERROR]
+
+
+def test_no_confirmation_is_logged_once(client, caplog):
+    with patch.object(bambu_mqtt.time, "monotonic", return_value=1_000.0):
+        client.skip_objects([7])
+
+    with (
+        patch.object(bambu_mqtt.time, "monotonic", return_value=1_000.0 + bambu_mqtt.SKIP_CONFIRM_TIMEOUT_S + 1),
+        caplog.at_level(logging.ERROR),
+    ):
+        client._process_message({"print": {"command": "push_status", "mc_percent": 50}})
+        client._process_message({"print": {"command": "push_status", "mc_percent": 51}})
+
+    errors = [r for r in caplog.records if r.levelno >= logging.ERROR]
+    assert len(errors) == 1 and "did not confirm" in errors[0].message
+
+
+def test_a_print_that_ends_first_logs_nothing(client, caplog):
+    with patch.object(bambu_mqtt.time, "monotonic", return_value=1_000.0):
+        client.skip_objects([7])
+    client._process_message({"print": {"command": "push_status", "gcode_state": "FINISH"}})
+
+    with (
+        patch.object(bambu_mqtt.time, "monotonic", return_value=1_000.0 + bambu_mqtt.SKIP_CONFIRM_TIMEOUT_S + 1),
+        caplog.at_level(logging.ERROR),
+    ):
+        client._process_message({"print": {"command": "push_status", "mc_percent": 0}})
+
+    assert not [r for r in caplog.records if "confirm" in r.message]

+ 2 - 1
frontend/src/__tests__/pages/PrintersPageDryingFeedback.test.tsx

@@ -177,8 +177,9 @@ describe('PrintersPage - AMS drying feedback (#2533)', () => {
     });
     await user.click(screen.getAllByTitle('Stop Drying')[0]);
 
+    // Sent, not "stopped": the AMS reports the end of the cycle later.
     await waitFor(() => {
-      expect(mockShowToast).toHaveBeenCalledWith('Drying stopped', 'success');
+      expect(mockShowToast).toHaveBeenCalledWith('Stop command sent', 'success');
     });
   });
 

+ 22 - 0
frontend/src/__tests__/utils/kprofileWrite.test.ts

@@ -0,0 +1,22 @@
+/**
+ * A K-profile write the printer did not acknowledge is reported as sent, not
+ * as saved.
+ */
+import { describe, it, expect } from 'vitest';
+import { kprofileWriteToast } from '../../utils/kprofileWrite';
+
+describe('kprofileWriteToast', () => {
+  it('says saved when the printer confirmed it', () => {
+    expect(kprofileWriteToast({ success: true, confirmed: true, message: '' }, 'kProfiles.toast.profileSaved'))
+      .toEqual({ key: 'kProfiles.toast.profileSaved', type: 'success' });
+  });
+
+  it('warns when the printer did not confirm it', () => {
+    expect(kprofileWriteToast({ success: true, confirmed: false, message: '' }, 'kProfiles.toast.profileSaved'))
+      .toEqual({ key: 'kProfiles.toast.writeUnconfirmed', type: 'warning' });
+  });
+
+  it('keeps the old answer shape working', () => {
+    expect(kprofileWriteToast({ success: true, message: '' }, 'kProfiles.toast.profileDeleted').type).toBe('success');
+  });
+});

+ 4 - 3
frontend/src/api/client.ts

@@ -1,3 +1,4 @@
+import type { KProfileWriteResult } from '../utils/kprofileWrite';
 import type { ArchivePlatesResponse, LibraryFilePlatesResponse } from '../types/plates';
 
 const API_BASE = '/api/v1';
@@ -6597,17 +6598,17 @@ export const api = {
   getKProfiles: (printerId: number, nozzleDiameter = '0.4') =>
     request<KProfilesResponse>(`/printers/${printerId}/kprofiles/?nozzle_diameter=${nozzleDiameter}`),
   setKProfile: (printerId: number, profile: KProfileCreate) =>
-    request<{ success: boolean; message: string }>(`/printers/${printerId}/kprofiles/`, {
+    request<KProfileWriteResult>(`/printers/${printerId}/kprofiles/`, {
       method: 'POST',
       body: JSON.stringify(profile),
     }),
   deleteKProfile: (printerId: number, profile: KProfileDelete) =>
-    request<{ success: boolean; message: string }>(`/printers/${printerId}/kprofiles/`, {
+    request<KProfileWriteResult>(`/printers/${printerId}/kprofiles/`, {
       method: 'DELETE',
       body: JSON.stringify(profile),
     }),
   setKProfilesBatch: (printerId: number, profiles: KProfileCreate[]) =>
-    request<{ success: boolean; message: string }>(`/printers/${printerId}/kprofiles/batch`, {
+    request<KProfileWriteResult>(`/printers/${printerId}/kprofiles/batch`, {
       method: 'POST',
       body: JSON.stringify(profiles),
     }),

+ 16 - 6
frontend/src/components/KProfilesView.tsx

@@ -32,6 +32,7 @@ import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
 import { useCancellableTimeout } from '../hooks/useCancellableTimeout';
+import { kprofileWriteToast, writeUnconfirmed } from '../utils/kprofileWrite';
 
 interface KProfileCardProps {
   profile: KProfile;
@@ -269,7 +270,8 @@ function KProfileModal({
     },
     onSuccess: (result, variables) => {
       console.log('[KProfile] Save success:', result);
-      showToast(t('kProfiles.toast.profileSaved'));
+      const toast = kprofileWriteToast(result, 'kProfiles.toast.profileSaved');
+      showToast(t(toast.key), toast.type);
       // Save note if it changed (including clearing it)
       if (onSaveNote && note !== initialNote) {
         let profileKey: string;
@@ -310,7 +312,8 @@ function KProfileModal({
     },
     onSuccess: (result) => {
       console.log('[KProfile] Delete success:', result);
-      showToast(t('kProfiles.toast.profileDeleted'));
+      const toast = kprofileWriteToast(result, 'kProfiles.toast.profileDeleted');
+      showToast(t(toast.key), toast.type);
       // Show syncing indicator while printer processes the command
       setIsSyncing(true);
       // Add longer delay for delete - printer needs more time to process
@@ -423,8 +426,9 @@ function KProfileModal({
     console.log(`[KProfile] Saving ${batchPayload.length} profiles in batch:`, batchPayload);
 
     try {
-      await api.setKProfilesBatch(printerId, batchPayload);
-      showToast(t('kProfiles.toast.profilesSaved', { count: selectedExtruders.length }));
+      const result = await api.setKProfilesBatch(printerId, batchPayload);
+      const toast = kprofileWriteToast(result, 'kProfiles.toast.profilesSaved');
+      showToast(t(toast.key, { count: selectedExtruders.length }), toast.type);
       // Save note for new batch profiles
       if (onSaveNote && note) {
         const profileKey = `name_${name}_${resolvedFilamentId}`;
@@ -1139,11 +1143,12 @@ export function KProfilesView() {
 
         // Import profiles one by one
         let imported = 0;
+        let unconfirmed = 0;
         for (const p of data.profiles) {
           if (!p.name || !p.k_value || !p.filament_id) continue;
 
           try {
-            await api.setKProfile(selectedPrinter!, {
+            const result = await api.setKProfile(selectedPrinter!, {
               name: p.name,
               k_value: parseFloat(p.k_value).toFixed(6),
               filament_id: p.filament_id,
@@ -1156,6 +1161,7 @@ export function KProfilesView() {
               slot_id: 0, // Always create new
             });
             imported++;
+            if (writeUnconfirmed(result)) unconfirmed++;
             // Small delay between imports
             await new Promise(resolve => setTimeout(resolve, 500));
           } catch (err) {
@@ -1164,6 +1170,7 @@ export function KProfilesView() {
         }
 
         showToast(t('kProfiles.toast.importedProfiles', { count: imported, total: data.profiles.length }));
+        if (unconfirmed > 0) showToast(t('kProfiles.toast.writesUnconfirmed', { count: unconfirmed }), 'warning');
         refetchProfiles();
       } catch (err) {
         console.error('Import error:', err);
@@ -1203,9 +1210,10 @@ export function KProfilesView() {
     setBulkDeleteInProgress(true);
 
     let deleted = 0;
+    let unconfirmed = 0;
     for (const profile of profilesToDelete) {
       try {
-        await api.deleteKProfile(selectedPrinter!, {
+        const result = await api.deleteKProfile(selectedPrinter!, {
           slot_id: profile.slot_id,
           extruder_id: profile.extruder_id,
           nozzle_id: profile.nozzle_id,
@@ -1214,6 +1222,7 @@ export function KProfilesView() {
           setting_id: profile.setting_id,
         });
         deleted++;
+        if (writeUnconfirmed(result)) unconfirmed++;
         // Small delay between deletes
         await new Promise(resolve => setTimeout(resolve, 300));
       } catch (err) {
@@ -1222,6 +1231,7 @@ export function KProfilesView() {
     }
 
     showToast(t('kProfiles.toast.profilesDeleted', { count: deleted }));
+    if (unconfirmed > 0) showToast(t('kProfiles.toast.writesUnconfirmed', { count: unconfirmed }), 'warning');
     setBulkDeleteInProgress(false);
     setShowBulkDeleteConfirm(false);
     setSelectionMode(false);

+ 3 - 1
frontend/src/i18n/locales/de.ts

@@ -764,7 +764,7 @@ export default {
       cannotDryNow: 'AMS kann das Trocknen gerade nicht starten',
       startingDrying: 'Trocknung wird gestartet...',
       toastCommandSent: 'Trocknungsbefehl gesendet',
-      toastStopped: 'Trocknung gestoppt',
+      toastStopped: 'Stoppbefehl gesendet',
       toastNotStarted: 'Der Drucker hat den Befehl angenommen, aber das AMS hat die Trocknung nicht gestartet. Prüfe, ob das AMS-Netzteil angeschlossen ist und der Drucker im Leerlauf ist.',
       screenOnly: 'Die AMS-Trocknung kann bei diesem Drucker nur am Bildschirm des Druckers selbst gesteuert werden (Einschränkung von Bambu)',
       stoppingDrying: 'Trocknung wird gestoppt...',
@@ -6076,6 +6076,8 @@ export default {
     // Toast
     toast: {
       profileSaved: 'K-Profil gespeichert',
+      writeUnconfirmed: 'An den Drucker gesendet, aber nicht bestätigt. Prüfe die Liste nach dem Aktualisieren.',
+      writesUnconfirmed: 'Der Drucker hat {{count}} davon nicht bestätigt. Prüfe die Liste nach dem Aktualisieren.',
       profilesSaved: 'K-Profil auf {{count}} Extrudern gespeichert',
       selectAtLeastOneExtruder: 'Bitte wählen Sie mindestens einen Extruder aus',
       selectFilament: 'Bitte zuerst ein Filament auswählen',

+ 3 - 1
frontend/src/i18n/locales/en.ts

@@ -769,7 +769,7 @@ export default {
       cannotDryNow: 'AMS can\'t start drying right now',
       startingDrying: 'Starting drying...',
       toastCommandSent: 'Drying command sent',
-      toastStopped: 'Drying stopped',
+      toastStopped: 'Stop command sent',
       toastNotStarted: 'The printer accepted the command but the AMS never started drying. Check that the AMS power adapter is connected and that the printer is idle.',
       screenOnly: 'AMS drying on this printer can only be controlled from the printer\'s own screen (Bambu limitation)',
       stoppingDrying: 'Stopping drying...',
@@ -6129,6 +6129,8 @@ export default {
     // Toast
     toast: {
       profileSaved: 'K-profile saved',
+      writeUnconfirmed: 'Sent to the printer, but it did not confirm. Check the list once it refreshes.',
+      writesUnconfirmed: 'The printer did not confirm {{count}} of them. Check the list once it refreshes.',
       profilesSaved: 'K-profile saved to {{count}} extruders',
       selectAtLeastOneExtruder: 'Please select at least one extruder',
       selectFilament: 'Select a filament first',

+ 3 - 1
frontend/src/i18n/locales/es.ts

@@ -764,7 +764,7 @@ export default {
       cannotDryNow: 'El AMS no puede iniciar el secado en este momento',
       startingDrying: 'Iniciando el secado...',
       toastCommandSent: 'Comando de secado enviado',
-      toastStopped: 'Secado detenido',
+      toastStopped: 'Orden de detención enviada',
       toastNotStarted: 'La impresora aceptó el comando, pero el AMS no inició el secado. Comprueba que el adaptador de corriente del AMS esté conectado y que la impresora esté inactiva.',
       screenOnly: 'En esta impresora, el secado del AMS solo se puede controlar desde la pantalla de la propia impresora (limitación de Bambu)',
       stoppingDrying: 'Deteniendo el secado...',
@@ -6084,6 +6084,8 @@ export default {
     // Toast
     toast: {
       profileSaved: 'Perfil K guardado',
+      writeUnconfirmed: 'Enviado a la impresora, pero no lo confirmó. Revisa la lista cuando se actualice.',
+      writesUnconfirmed: 'La impresora no confirmó {{count}} de ellos. Revisa la lista cuando se actualice.',
       profilesSaved: 'Perfil K guardado en {{count}} extrusores',
       selectAtLeastOneExtruder: 'Seleccione al menos un extrusor',
       selectFilament: 'Selecciona primero un filamento',

+ 3 - 1
frontend/src/i18n/locales/fr.ts

@@ -764,7 +764,7 @@ export default {
       cannotDryNow: 'L\'AMS ne peut pas démarrer le séchage pour le moment',
       startingDrying: 'Démarrage du séchage...',
       toastCommandSent: 'Commande de séchage envoyée',
-      toastStopped: 'Séchage arrêté',
+      toastStopped: "Commande d'arrêt envoyée",
       toastNotStarted: 'L\'imprimante a accepté la commande, mais l\'AMS n\'a pas démarré le séchage. Vérifiez que l\'adaptateur secteur de l\'AMS est branché et que l\'imprimante est inactive.',
       screenOnly: 'Sur cette imprimante, le séchage de l\'AMS ne peut être commandé que depuis l\'écran de l\'imprimante (limitation de Bambu)',
       stoppingDrying: 'Arrêt du séchage...',
@@ -6064,6 +6064,8 @@ export default {
     // Toast
     toast: {
       profileSaved: 'Profil K enregistré',
+      writeUnconfirmed: "Envoyé à l'imprimante, mais elle n'a pas confirmé. Vérifiez la liste après son actualisation.",
+      writesUnconfirmed: "L'imprimante n'en a pas confirmé {{count}}. Vérifiez la liste après son actualisation.",
       profilesSaved: 'Profil K enregistré sur {{count}} extrudeur(s)',
       selectAtLeastOneExtruder: 'Sélectionnez un extrudeur',
       selectFilament: 'Sélectionnez d’abord un filament',

+ 3 - 1
frontend/src/i18n/locales/it.ts

@@ -764,7 +764,7 @@ export default {
       cannotDryNow: 'L\'AMS non può avviare l\'asciugatura al momento',
       startingDrying: 'Avvio essiccazione...',
       toastCommandSent: 'Comando di essiccazione inviato',
-      toastStopped: 'Essiccazione interrotta',
+      toastStopped: 'Comando di arresto inviato',
       toastNotStarted: 'La stampante ha accettato il comando, ma l\'AMS non ha avviato l\'essiccazione. Verifica che l\'alimentatore dell\'AMS sia collegato e che la stampante sia inattiva.',
       screenOnly: 'Su questa stampante l\'essiccazione dell\'AMS può essere comandata solo dallo schermo della stampante (limitazione di Bambu)',
       stoppingDrying: 'Arresto essiccazione...',
@@ -6063,6 +6063,8 @@ export default {
     // Toast
     toast: {
       profileSaved: 'K-profile salvato',
+      writeUnconfirmed: "Inviato alla stampante, ma non ha confermato. Controlla l'elenco dopo l'aggiornamento.",
+      writesUnconfirmed: "La stampante non ne ha confermati {{count}}. Controlla l'elenco dopo l'aggiornamento.",
       profilesSaved: 'K-profile salvato su {{count}} estrusori',
       selectAtLeastOneExtruder: 'Seleziona almeno un estrusore',
       selectFilament: 'Seleziona prima un filamento',

+ 3 - 1
frontend/src/i18n/locales/ja.ts

@@ -763,7 +763,7 @@ export default {
       cannotDryNow: 'AMSは現在乾燥を開始できません',
       startingDrying: '乾燥を開始しています...',
       toastCommandSent: '乾燥コマンドを送信しました',
-      toastStopped: '乾燥を停止しました',
+      toastStopped: '停止コマンドを送信しました',
       toastNotStarted: 'プリンターはコマンドを受け付けましたが、AMS は乾燥を開始しませんでした。AMS の電源アダプターが接続されているか、プリンターがアイドル状態かを確認してください。',
       screenOnly: 'このプリンターでは、AMS の乾燥はプリンター本体の画面からのみ操作できます (Bambu の仕様上の制限)',
       stoppingDrying: '乾燥を停止しています...',
@@ -6077,6 +6077,8 @@ export default {
     // Toast
     toast: {
       profileSaved: 'Kプロファイルを保存しました',
+      writeUnconfirmed: 'プリンターに送信しましたが、確認がありませんでした。更新後に一覧を確認してください。',
+      writesUnconfirmed: 'そのうち {{count}} 件はプリンターから確認がありませんでした。更新後に一覧を確認してください。',
       profilesSaved: 'Kプロファイルを{{count}}台のエクストルーダーに保存しました',
       selectAtLeastOneExtruder: 'エクストルーダーを1つ以上選択してください',
       selectFilament: '先にフィラメントを選択してください',

+ 3 - 1
frontend/src/i18n/locales/ko.ts

@@ -726,7 +726,7 @@ export default {
       cannotDryNow: 'AMS가 지금은 건조를 시작할 수 없습니다',
       startingDrying: '건조 시작 중...',
       toastCommandSent: '건조 명령을 전송했습니다',
-      toastStopped: '건조를 중지했습니다',
+      toastStopped: '중지 명령을 보냈습니다',
       toastNotStarted: '프린터가 명령을 수락했지만 AMS가 건조를 시작하지 않았습니다. AMS 전원 어댑터가 연결되어 있는지, 프린터가 대기 상태인지 확인하십시오.',
       screenOnly: '이 프린터에서는 AMS 건조를 프린터 자체 화면에서만 제어할 수 있습니다 (Bambu 제한 사항)',
       stoppingDrying: '건조 정지 중...',
@@ -5814,6 +5814,8 @@ export default {
     },
     toast: {
       profileSaved: 'K-프로필 저장됨',
+      writeUnconfirmed: '프린터로 보냈지만 확인 응답이 없습니다. 목록이 새로 고쳐지면 확인하세요.',
+      writesUnconfirmed: '그중 {{count}}개는 프린터가 확인하지 않았습니다. 목록이 새로 고쳐지면 확인하세요.',
       profilesSaved: '{{count}}개 압출기에 K-프로필 저장됨',
       selectAtLeastOneExtruder: '적어도 하나의 압출기를 선택해 주세요',
       selectFilament: '먼저 필라먼트를 선택하세요',

+ 3 - 1
frontend/src/i18n/locales/nl.ts

@@ -767,7 +767,7 @@ export default {
       cannotDryNow: 'AMS kan op dit moment niet beginnen met drogen',
       startingDrying: 'Drogen starten...',
       toastCommandSent: 'Droogopdracht verzonden',
-      toastStopped: 'Drogen gestopt',
+      toastStopped: 'Stopopdracht verzonden',
       toastNotStarted: 'De printer heeft de opdracht geaccepteerd, maar de AMS is niet begonnen met drogen. Controleer of de AMS-voedingsadapter is aangesloten en de printer inactief is.',
       screenOnly: 'AMS-drogen op deze printer kan alleen via het eigen scherm van de printer worden bediend (Bambu-beperking)',
       stoppingDrying: 'Drogen stoppen...',
@@ -6127,6 +6127,8 @@ export default {
     // Toast
     toast: {
       profileSaved: 'K-profiel opgeslagen',
+      writeUnconfirmed: 'Naar de printer verzonden, maar niet bevestigd. Controleer de lijst na het verversen.',
+      writesUnconfirmed: 'De printer heeft er {{count}} niet bevestigd. Controleer de lijst na het verversen.',
       profilesSaved: 'K-profiel opgeslagen naar {{count}} extruders',
       selectAtLeastOneExtruder: 'Selecteer ten minste één extruder',
       selectFilament: 'Selecteer eerst een filament',

+ 3 - 1
frontend/src/i18n/locales/pt-BR.ts

@@ -764,7 +764,7 @@ export default {
       cannotDryNow: 'O AMS não pode iniciar a secagem no momento',
       startingDrying: 'Iniciando secagem...',
       toastCommandSent: 'Comando de secagem enviado',
-      toastStopped: 'Secagem interrompida',
+      toastStopped: 'Comando de parada enviado',
       toastNotStarted: 'A impressora aceitou o comando, mas o AMS não iniciou a secagem. Verifique se o adaptador de energia do AMS está conectado e se a impressora está ociosa.',
       screenOnly: 'Nesta impressora, a secagem do AMS só pode ser controlada pela tela da própria impressora (limitação da Bambu)',
       stoppingDrying: 'Parando secagem...',
@@ -6063,6 +6063,8 @@ export default {
     // Toast
     toast: {
       profileSaved: 'K-profile salvo',
+      writeUnconfirmed: 'Enviado à impressora, mas ela não confirmou. Verifique a lista quando atualizar.',
+      writesUnconfirmed: 'A impressora não confirmou {{count}} deles. Verifique a lista quando atualizar.',
       profilesSaved: 'K-profile salvo em {{count}} extrusores',
       selectAtLeastOneExtruder: 'Por favor, selecione pelo menos um extrusor',
       selectFilament: 'Selecione um filamento primeiro',

+ 3 - 1
frontend/src/i18n/locales/ru.ts

@@ -731,7 +731,7 @@ export default {
       cannotDryNow: "AMS сейчас не может начать сушку",
       startingDrying: "Запуск сушки...",
       toastCommandSent: "Команда сушки отправлена",
-      toastStopped: "Сушка остановлена",
+      toastStopped: 'Команда остановки отправлена',
       toastNotStarted: "Принтер принял команду, но AMS не начал сушку. Убедитесь, что адаптер питания AMS подключён, а принтер не занят.",
       screenOnly: "На этом принтере управлять сушкой AMS можно только с экрана самого принтера (ограничение Bambu)",
       stoppingDrying: "Остановка сушки...",
@@ -5802,6 +5802,8 @@ export default {
     },
     toast: {
       profileSaved: "K-профиль сохранён",
+      writeUnconfirmed: 'Отправлено на принтер, но подтверждения нет. Проверьте список после обновления.',
+      writesUnconfirmed: 'Принтер не подтвердил {{count}} из них. Проверьте список после обновления.',
       profilesSaved: "K-профиль сохранён для {{count}} экструдеров",
       selectAtLeastOneExtruder: "Выберите хотя бы один экструдер",
       selectFilament: "Сначала выберите филамент",

+ 3 - 1
frontend/src/i18n/locales/sv.ts

@@ -767,7 +767,7 @@ export default {
       cannotDryNow: 'AMS kan inte börja torka just nu',
       startingDrying: 'Startar torkning...',
       toastCommandSent: 'Torkningskommando skickat',
-      toastStopped: 'Torkning stoppad',
+      toastStopped: 'Stoppkommando skickat',
       toastNotStarted: 'Skrivaren accepterade kommandot men AMS startade aldrig torkning. Kontrollera att AMS-strömadaptern är ansluten och att skrivaren är inaktiv.',
       screenOnly: 'AMS-torkning på denna skrivare kan endast kontrolleras från skrivarens egen skärm (Bambu-begränsning)',
       stoppingDrying: 'Stoppar torkning...',
@@ -6125,6 +6125,8 @@ errors: {
     // Toast
     toast: {
       profileSaved: 'K-profil sparad',
+      writeUnconfirmed: 'Skickat till skrivaren, men den bekräftade inte. Kontrollera listan när den uppdaterats.',
+      writesUnconfirmed: 'Skrivaren bekräftade inte {{count}} av dem. Kontrollera listan när den uppdaterats.',
       profilesSaved: 'K-profil sparad till {{count}} extrudrar',
       selectAtLeastOneExtruder: 'Välj minst en extruder',
       selectFilament: 'Välj ett filament först',

+ 3 - 1
frontend/src/i18n/locales/tr.ts

@@ -764,7 +764,7 @@ export default {
       cannotDryNow: 'AMS şu anda kurutmayı başlatamıyor',
       startingDrying: 'Kurutma başlatılıyor...',
       toastCommandSent: 'Kurutma komutu gönderildi',
-      toastStopped: 'Kurutma durduruldu',
+      toastStopped: 'Durdurma komutu gönderildi',
       toastNotStarted: 'Yazıcı komutu kabul etti ancak AMS kurutmayı başlatmadı. AMS güç adaptörünün bağlı olduğundan ve yazıcının boşta olduğundan emin olun.',
       screenOnly: 'Bu yazıcıda AMS kurutma yalnızca yazıcının kendi ekranından kontrol edilebilir (Bambu kısıtlaması)',
       stoppingDrying: 'Kurutma durduruluyor...',
@@ -6040,6 +6040,8 @@ export default {
     },
     toast: {
       profileSaved: 'K-profili kaydedildi',
+      writeUnconfirmed: 'Yazıcıya gönderildi ancak onaylanmadı. Liste yenilendiğinde kontrol edin.',
+      writesUnconfirmed: 'Yazıcı bunlardan {{count}} tanesini onaylamadı. Liste yenilendiğinde kontrol edin.',
       profilesSaved: '{{count}} ekstrüdere K-profili kaydedildi',
       selectAtLeastOneExtruder: 'Lütfen en az bir ekstrüder seçin',
       selectFilament: 'Önce bir filament seçin',

+ 3 - 1
frontend/src/i18n/locales/uk.ts

@@ -767,7 +767,7 @@ export default {
       cannotDryNow: "AMS зараз не може почати сушіння",
       startingDrying: "Запуск сушіння…",
       toastCommandSent: "Надіслано команду сушіння",
-      toastStopped: "Сушіння зупинено",
+      toastStopped: 'Команду зупинки надіслано',
       toastNotStarted: "Принтер прийняв команду, але AMS так і не почав сушити. Перевірте, чи підключено адаптер живлення AMS і чи принтер неактивний.",
       screenOnly: "Сушінням AMS на цьому принтері можна керувати лише з екрана принтера (обмеження Bambu)",
       stoppingDrying: "Припинення сушіння...",
@@ -6117,6 +6117,8 @@ export default {
     // Toast
     toast: {
       profileSaved: "K-профіль збережено",
+      writeUnconfirmed: 'Надіслано на принтер, але підтвердження немає. Перевірте список після оновлення.',
+      writesUnconfirmed: 'Принтер не підтвердив {{count}} з них. Перевірте список після оновлення.',
       profilesSaved: "K-профіль збережено в екструдери {{count}}.",
       selectAtLeastOneExtruder: "Виберіть принаймні один екструдер",
       selectFilament: "Спочатку виберіть філамент",

+ 3 - 1
frontend/src/i18n/locales/zh-CN.ts

@@ -764,7 +764,7 @@ export default {
       cannotDryNow: 'AMS 当前无法开始干燥',
       startingDrying: '正在启动干燥...',
       toastCommandSent: '已发送干燥命令',
-      toastStopped: '已停止干燥',
+      toastStopped: '已发送停止命令',
       toastNotStarted: '打印机已接受命令,但 AMS 未开始干燥。请检查 AMS 电源适配器是否已连接,以及打印机是否处于空闲状态。',
       screenOnly: '此打印机的 AMS 干燥只能在打印机自带的屏幕上操作(Bambu 的限制)',
       stoppingDrying: '正在停止干燥...',
@@ -6063,6 +6063,8 @@ export default {
     // Toast
     toast: {
       profileSaved: 'K 值配置已保存',
+      writeUnconfirmed: '已发送到打印机,但打印机未确认。刷新后请检查列表。',
+      writesUnconfirmed: '其中 {{count}} 项打印机未确认。刷新后请检查列表。',
       profilesSaved: 'K 值配置已保存到 {{count}} 个挤出机',
       selectAtLeastOneExtruder: '请至少选择一个挤出机',
       selectFilament: '请先选择耗材',

+ 3 - 1
frontend/src/i18n/locales/zh-TW.ts

@@ -764,7 +764,7 @@ export default {
       cannotDryNow: 'AMS 目前無法開始乾燥',
       startingDrying: '正在啟動乾燥...',
       toastCommandSent: '已傳送乾燥命令',
-      toastStopped: '已停止乾燥',
+      toastStopped: '已傳送停止命令',
       toastNotStarted: '印表機已接受命令,但 AMS 未開始乾燥。請檢查 AMS 電源變壓器是否已連接,以及印表機是否處於閒置狀態。',
       screenOnly: '此印表機的 AMS 乾燥只能在印表機本身的螢幕上操作(Bambu 的限制)',
       stoppingDrying: '正在停止乾燥...',
@@ -6063,6 +6063,8 @@ export default {
     // Toast
     toast: {
       profileSaved: 'K 值設定已儲存',
+      writeUnconfirmed: '已傳送到印表機,但印表機未確認。重新整理後請檢查清單。',
+      writesUnconfirmed: '其中 {{count}} 項印表機未確認。重新整理後請檢查清單。',
       profilesSaved: 'K 值設定已儲存到 {{count}} 個擠出機',
       selectAtLeastOneExtruder: '請至少選擇一個擠出機',
       selectFilament: '請先選擇耗材',

+ 25 - 0
frontend/src/utils/kprofileWrite.ts

@@ -0,0 +1,25 @@
+/**
+ * The answer of a K-profile write: `confirmed` is false when the printer did
+ * not acknowledge it in time. Firmware that predates the acknowledgement never
+ * sends one, so that is not a refusal, but it is not "saved" either.
+ */
+export interface KProfileWriteResult {
+  success: boolean;
+  confirmed?: boolean;
+  message: string;
+}
+
+/** Whether the printer left this write unacknowledged. */
+export function writeUnconfirmed(result: KProfileWriteResult): boolean {
+  return result.confirmed === false;
+}
+
+/** The toast for a write the printer accepted, confirmed or not. */
+export function kprofileWriteToast(
+  result: KProfileWriteResult,
+  confirmedKey: string,
+): { key: string; type: 'success' | 'warning' } {
+  return writeUnconfirmed(result)
+    ? { key: 'kProfiles.toast.writeUnconfirmed', type: 'warning' }
+    : { key: confirmedKey, type: 'success' };
+}

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/AdditionalSection-L-15MXNk.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/AmsUnitCard-CwBsWxvt.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/ArchivesPage-B4hbjTm5.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/AssignToAmsModal-BAA9H884.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/CamWallPage-B90Vk8W7.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/CameraDiagnoseModal-CXD2FTZY.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/CameraPage-DscTlQvs.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/CameraWall-XQqfi-uc.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/ColumnConfigModal-WzRBT7eu.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/ConfigureAmsSlotModal-DC9_cLDm.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/ConnectAuthorizePage-CQUx1amq.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/ContextMenu-BiM9sjmK.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/ExternalLinkPage-CSVHYlAP.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 1
static/assets/FileManagerPage-DXKdNPDt.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/FileUploadModal-BsT6a9pv.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/FinancePage-Ltm_FK79.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/GCodeViewerPage-MLPD1B1H.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/GroupEditPage-C3l2le7H.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/ImagePreviewModal-BF0BlhBB.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/InventoryPage-DjaDMrPH.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/LibraryTrashPage-BusrKlwO.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/LoginPage-DNnkmXTo.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/MaintenancePage-07IZyhJ2.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/ModelSourcesPage-BSGlDWqx.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/NotificationsPage-Ccsx2PK-.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 1
static/assets/PdfPreviewModal-DNCJdhP9.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/PhotoGalleryModal-BRQ7dkyl.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/PrinterLocationsPage-nsZN50k7.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/PrintersPage-CdUF8kSL.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/ProfilesPage-Blrzv8Jz.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/ProfilesPage-BuIp6mUb.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/ProjectDetailPage-B49WcQ1V.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/ProjectsPage-CmcOz5CS.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/QueuePage-C3qqdE-w.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/RichTextEditor-sa9WxUkB.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/SettingsPage-CqoOyPKy.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/SetupPage-uDTUZ0WY.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/SliceModal-BWbv4zZ_.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/SpoolBuddyAmsPage-ChteKbX4.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/SpoolBuddyDashboard-nxR-lH3x.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-Cy-CoWOm.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 1 - 1
static/assets/SpoolBuddySettingsPage-Dr-7pM8b.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-B4TAT5Wj.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/SpreadsheetPreviewModal-BIpa5wgb.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/StatsPage-CZjXoczc.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/StreamOverlayPage-By0n8WvQ.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/SystemInfoPage-jNhmkglP.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/de-BqH_BwEQ.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/de-cAJU3vlP.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/es-DMnPhPHh.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/es-Lnnz1GCC.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/fr-4FImw2fq.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/fr-DEhIPvJF.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/index-BQS5hDzk.js


+ 1 - 1
static/assets/inventorySearch-Dp-N6WfE.js → static/assets/inventorySearch-BMYQ29xO.js

@@ -1 +1 @@
-import{_t as e}from"./index-Cab7krcD.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};
+import{_t as e}from"./index-BQS5hDzk.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/it-B1z0Al3p.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/it-BvQDa5wf.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/ja-B2cLeNaQ.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/ja-DitLMdvB.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/ko-B2JMT7Xl.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/ko-Dwk4TaGQ.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/locationSensorDefaults-izjsR7C8.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/nl-BMI9lZx8.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/nl-BqAdEb0Z.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/pdf-8QJQv9CU.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/pt-BR-Bwas9MuF.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/pt-BR-GJJmaAhf.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/ru-Bzgmtaam.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/ru-Cy93M7Z2.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/sv-DDESJEYV.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/sv-zbZ8xp6l.js


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
static/assets/tr-Ch77jSWu.js


Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä